mirror of
https://github.com/narrrl/proton-stream.git
synced 2026-11-11 09:27:03 +01:00
improved android client, feature parity with desktop
This commit is contained in:
1 parent
352fdf1933
commit
f1b3ad9637
56 files changed
+9727
-1799
No files matched your search
@@ -2,6 +2,7 @@ package io.narl.protonstream.native
|
||||
|
||||
import android.content.Context
|
||||
import android.security.keystore.KeyGenParameterSpec
|
||||
import android.security.keystore.KeyPermanentlyInvalidatedException
|
||||
import android.security.keystore.KeyProperties
|
||||
import android.util.Base64
|
||||
import java.security.KeyStore
|
||||
@@ -10,22 +11,44 @@ import javax.crypto.KeyGenerator
|
||||
import javax.crypto.SecretKey
|
||||
import javax.crypto.spec.GCMParameterSpec
|
||||
import uniffi.pstr_android.AndroidSecretStore
|
||||
import uniffi.pstr_android.BridgeException
|
||||
|
||||
/** Encrypts share fragments and passwords before they reach app preferences. */
|
||||
/**
|
||||
* Encrypts share fragments and passwords before they reach app preferences.
|
||||
*
|
||||
* Every failure here leaves as a [BridgeException]. Raw Java exceptions crossing
|
||||
* the FFI have no mapping on the Rust side, and an unmapped callback exception
|
||||
* is a panic on whichever thread was calling — including a tokio worker holding
|
||||
* a download. What the caller does about it is a separate question; being told
|
||||
* at all is this class's part.
|
||||
*/
|
||||
class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
||||
private val preferences = context.getSharedPreferences(PREFERENCES, Context.MODE_PRIVATE)
|
||||
private val keyStore = KeyStore.getInstance("AndroidKeyStore").apply { load(null) }
|
||||
|
||||
override fun set(key: String, value: String) {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||
val encrypted = cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||
val payload = cipher.iv + encrypted
|
||||
override fun set(key: String, value: String) = bridging("store credential") {
|
||||
val payload = try {
|
||||
seal(value)
|
||||
} catch (invalidated: KeyPermanentlyInvalidatedException) {
|
||||
// A lockscreen change or a device restore retires the key. Nothing
|
||||
// it sealed can be read again — and nothing new can be sealed with
|
||||
// it either, so re-entering a link would fail here rather than
|
||||
// restoring access. Everything the old key held is already lost, so
|
||||
// replacing it costs nothing that is not gone.
|
||||
keyStore.deleteEntry(KEY_ALIAS)
|
||||
seal(value)
|
||||
}
|
||||
check(preferences.edit().putString(key, Base64.encodeToString(payload, Base64.NO_WRAP)).commit())
|
||||
}
|
||||
|
||||
override fun get(key: String): String? {
|
||||
val encoded = preferences.getString(key, null) ?: return null
|
||||
private fun seal(value: String): ByteArray {
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
cipher.init(Cipher.ENCRYPT_MODE, encryptionKey())
|
||||
return cipher.iv + cipher.doFinal(value.toByteArray(Charsets.UTF_8))
|
||||
}
|
||||
|
||||
override fun get(key: String): String? = bridging("read credential") {
|
||||
val encoded = preferences.getString(key, null) ?: return@bridging null
|
||||
val payload = Base64.decode(encoded, Base64.NO_WRAP)
|
||||
require(payload.size > IV_SIZE) { "Invalid encrypted credential" }
|
||||
val cipher = Cipher.getInstance(TRANSFORMATION)
|
||||
@@ -34,13 +57,23 @@ class KeystoreSecretStore(context: Context) : AndroidSecretStore {
|
||||
encryptionKey(),
|
||||
GCMParameterSpec(TAG_BITS, payload.copyOfRange(0, IV_SIZE)),
|
||||
)
|
||||
return cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
||||
cipher.doFinal(payload.copyOfRange(IV_SIZE, payload.size)).toString(Charsets.UTF_8)
|
||||
}
|
||||
|
||||
override fun delete(key: String) {
|
||||
override fun delete(key: String) = bridging("delete credential") {
|
||||
check(preferences.edit().remove(key).commit())
|
||||
}
|
||||
|
||||
private fun <T> bridging(action: String, block: () -> T): T = try {
|
||||
block()
|
||||
} catch (error: BridgeException) {
|
||||
throw error
|
||||
} catch (error: Exception) {
|
||||
// The message, never the stack trace: this reaches a snackbar, and the
|
||||
// trace is both unreadable there and a description of app internals.
|
||||
throw BridgeException.Failure("Could not $action: ${error.message ?: error::class.java.simpleName}")
|
||||
}
|
||||
|
||||
private fun encryptionKey(): SecretKey {
|
||||
(keyStore.getKey(KEY_ALIAS, null) as? SecretKey)?.let { return it }
|
||||
return KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, "AndroidKeyStore").run {
|
||||
|
||||
@@ -12,14 +12,23 @@ object NativeRuntime {
|
||||
private lateinit var appContext: Context
|
||||
@Volatile private var instance: AndroidEngine? = null
|
||||
|
||||
/**
|
||||
* Whether Android's certificate verifier came up.
|
||||
*
|
||||
* False means every HTTPS request will fail validation, which otherwise
|
||||
* surfaces much later as an unexplained certificate error against a share.
|
||||
*/
|
||||
@Volatile var tlsReady: Boolean = false
|
||||
private set
|
||||
|
||||
fun initialize(context: Context) {
|
||||
System.loadLibrary("pstr_android")
|
||||
initTls(context)
|
||||
tlsReady = initTls(context)
|
||||
appContext = context.applicationContext
|
||||
}
|
||||
|
||||
/** Initializes Android's certificate verifier before Proton networking. */
|
||||
private external fun initTls(context: Context)
|
||||
private external fun initTls(context: Context): Boolean
|
||||
|
||||
suspend fun engine(): AndroidEngine = withContext(Dispatchers.IO) {
|
||||
instance ?: synchronized(this@NativeRuntime) {
|
||||
|
||||
Reference in new issue
Block a user