diff --git a/.idea/dictionaries/project.xml b/.idea/dictionaries/project.xml index ac00a9c..eef31d2 100644 --- a/.idea/dictionaries/project.xml +++ b/.idea/dictionaries/project.xml @@ -1,8 +1,14 @@ + Aead + Nbits + Tink amboss + hkdf lnbc + lnbits + lnurlp msat satoshis sats diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e18b608..dad0536 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,7 +1,17 @@ +import com.google.protobuf.gradle.* + +configurations.all { + resolutionStrategy { + force("com.google.protobuf:protobuf-javalite:${libs.versions.protobuf.get()}") + force("com.google.protobuf:protobuf-kotlin-lite:${libs.versions.protobuf.get()}") + } +} + plugins { alias(libs.plugins.android.application) alias(libs.plugins.kotlin.compose) alias(libs.plugins.ksp) + alias(libs.plugins.protobuf) } android { @@ -39,6 +49,13 @@ android { compose = true buildConfig = true } + sourceSets { + getByName("main") { + proto { + srcDir("src/main/proto") + } + } + } } dependencies { @@ -72,6 +89,11 @@ dependencies { implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.compose) + // DataStore + Proto + Tink + implementation(libs.datastore.proto) + implementation(libs.protobuf.kotlin.lite) + implementation(libs.tink.android) + // DataStore implementation(libs.androidx.datastore.preferences) @@ -99,5 +121,22 @@ dependencies { // animations implementation(libs.androidx.compose.animation) +} -} \ No newline at end of file +protobuf { + protoc { + artifact = libs.protobuf.protoc.get().toString() + } + generateProtoTasks { + all().configureEach { // no "task ->" here + builtins { + id("java") { + option("lite") + } + id("kotlin") { + option("lite") + } + } + } + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt index 1f2e975..ccdc842 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/MainActivity.kt @@ -3,7 +3,6 @@ package com.bitcointxoko.gudariwallet import android.Manifest import android.content.ActivityNotFoundException import android.content.Intent -import android.net.Uri import android.os.Build import android.os.Bundle import android.os.PowerManager @@ -17,6 +16,9 @@ import androidx.appcompat.app.AppCompatActivity import androidx.compose.runtime.* import androidx.core.content.edit import androidx.core.net.toUri +import androidx.lifecycle.lifecycleScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import com.bitcointxoko.gudariwallet.security.EncryptedSecretStore import com.bitcointxoko.gudariwallet.service.WalletNotificationService import com.bitcointxoko.gudariwallet.ui.OnboardingScreen @@ -27,6 +29,8 @@ import com.bitcointxoko.gudariwallet.ui.theme.GudariWalletTheme import com.bitcointxoko.gudariwallet.util.NotificationHelper import com.bitcointxoko.gudariwallet.util.IntentUtils import kotlinx.coroutines.flow.MutableSharedFlow +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue class MainActivity : AppCompatActivity() { @@ -57,8 +61,13 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) - secretStore = EncryptedSecretStore(applicationContext) + val credentialsState = secretStore.credentials + .stateIn( + scope = lifecycleScope, + started = SharingStarted.WhileSubscribed(5_000), + initialValue = null + ) // Must be first — before any notification is posted NotificationHelper.createChannels(applicationContext) @@ -72,7 +81,10 @@ class MainActivity : AppCompatActivity() { setContent { GudariWalletTheme { - var isOnboarded by remember { mutableStateOf(secretStore.isOnboarded) } + val credentials by credentialsState.collectAsState() + + // Treat null (loading) the same as not-onboarded to avoid a flash + val isOnboarded = credentials?.isOnboarded == true if (isOnboarded) { LaunchedEffect(Unit) { @@ -80,20 +92,20 @@ class MainActivity : AppCompatActivity() { requestNotificationPermissionIfNeeded() promptBatteryOptimizationIfNeeded() } - // Pass pendingPaymentHash so WalletScreen can consume it WalletScreen( - vm = vm, - pendingPaymentHash = pendingPaymentHash, + vm = vm, + pendingPaymentHash = pendingPaymentHash, pendingPaymentUri = pendingPaymentUri ) } else { OnboardingScreen( secretStore = secretStore, - onComplete = { isOnboarded = true } + onComplete = { /* no-op: Flow update triggers recomposition automatically */ } ) } } } + } // Called when the activity is already running and a notification is tapped diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt index a2cc939..0aa62e0 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/api/LNbitsClient.kt @@ -1,7 +1,11 @@ package com.bitcointxoko.gudariwallet.api import com.bitcointxoko.gudariwallet.BuildConfig +import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.util.WalletConstants +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit @@ -25,10 +29,14 @@ object LNbitsClient { } .build() - fun create(baseUrl: String): LNbitsApi { - val url = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" + fun create(secrets: SecretStore): LNbitsApi { + val baseUrl = runBlocking(Dispatchers.IO) { + secrets.credentials.first().baseUrl + }.ifBlank { "https://placeholder.invalid" } + val normalizedUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" + return Retrofit.Builder() - .baseUrl(url) + .baseUrl(normalizedUrl) .client(httpClient) .addConverterFactory(GsonConverterFactory.create(GsonProvider.gson)) .build() diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt index ec2dbc1..758ece1 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/data/LNbitsWalletRepository.kt @@ -18,7 +18,7 @@ class LNbitsWalletRepository( // ── Balance ─────────────────────────────────────────────────────────────── override suspend fun getBalance(): WalletBalance { - val response = api.getWallet(secrets.invoiceKey) + val response = api.getWallet(secrets.invoiceKey()) return WalletBalance(sats = response.balance / WalletConstants.MSAT_PER_SAT) } @@ -26,7 +26,7 @@ class LNbitsWalletRepository( override suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice { val response = api.createInvoice( - secrets.invoiceKey, + secrets.invoiceKey(), CreateInvoiceRequest( out = false, amount = amountSats, @@ -41,13 +41,13 @@ class LNbitsWalletRepository( } override suspend fun isPaymentReceived(paymentHash: String): Boolean { - return api.checkPayment(secrets.invoiceKey, paymentHash).paid + return api.checkPayment(secrets.invoiceKey(), paymentHash).paid } // ── Send ────────────────────────────────────────────────────────────────── override suspend fun decodeBolt11(bolt11: String): DecodedBolt11 { - val response = api.decodeInvoice(secrets.invoiceKey, DecodeInvoiceRequest(data = bolt11)) + val response = api.decodeInvoice(secrets.invoiceKey(), DecodeInvoiceRequest(data = bolt11)) val amountSats = response.amountMsat ?.takeIf { it > 0L } ?.div(WalletConstants.MSAT_PER_SAT) @@ -64,7 +64,7 @@ class LNbitsWalletRepository( override suspend fun scanLnurl(code: String): ScannedLnurl { - val r = api.lnurlScan(secrets.invoiceKey, code) + val r = api.lnurlScan(secrets.invoiceKey(), code) return ScannedLnurl( tag = r.tag, callback = r.callback, @@ -169,7 +169,7 @@ class LNbitsWalletRepository( override suspend fun payBolt11(bolt11: String): PaymentConfirmation { val response = api.payInvoice( - secrets.adminKey, + secrets.adminKey(), PayInvoiceRequest(out = true, bolt11 = bolt11) ) return PaymentConfirmation(paymentHash = response.paymentHash) @@ -182,7 +182,7 @@ class LNbitsWalletRepository( comment: String? ): PaymentConfirmation { val response = api.payLnurl( - secrets.adminKey, + secrets.adminKey(), PayLnurlRequest( res = LnurlPayRes( tag = rawRes.tag, @@ -220,10 +220,10 @@ class LNbitsWalletRepository( } override suspend fun getLightningAddress(): String? { - val links = api.getLnurlpLinks(secrets.invoiceKey, allWallets = false) + val links = api.getLnurlpLinks(secrets.invoiceKey(), allWallets = false) val username = links.firstOrNull { !it.username.isNullOrBlank() }?.username ?: return null - val domain = secrets.baseUrl + val domain = secrets.baseUrl() .removePrefix("https://") .removePrefix("http://") .trimEnd('/') @@ -246,10 +246,10 @@ class LNbitsWalletRepository( // ── History ─────────────────────────────────────────────────────────────── override suspend fun getPayments(offset: Int, limit: Int): List { - return api.getPayments(secrets.invoiceKey, limit, offset) + return api.getPayments(secrets.invoiceKey(), limit, offset) } override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse { - return api.getPaymentDetail(secrets.invoiceKey, checkingId) + return api.getPaymentDetail(secrets.invoiceKey(), checkingId) } } diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsDataStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsDataStore.kt new file mode 100644 index 0000000..da96c3f --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsDataStore.kt @@ -0,0 +1,54 @@ +package com.bitcointxoko.gudariwallet.security + +import android.content.Context +import android.util.Log +import androidx.datastore.core.DataStore +import androidx.datastore.core.DataStoreFactory +import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler +import androidx.datastore.migrations.SharedPreferencesMigration +import androidx.datastore.migrations.SharedPreferencesView +import com.bitcointxoko.gudariwallet.util.WalletConstants +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import java.io.File +import androidx.core.content.edit + +/** + * Singleton DataStore for encrypted credentials. + * + * On first access after install/update, [SharedPreferencesMigration] will + * automatically read the old SharedPreferences values (decrypting them via + * [SecretManager]), write them into this DataStore, and mark migration done. + * + * The old SharedPreferences XML file is NOT deleted automatically — call + * [cleanUpLegacyPrefs] once you've confirmed migration succeeded. + */ +object CredentialsDataStore { + private const val TAG = "CredentialsDataStore" + + @Volatile + private var instance: DataStore? = null + + fun getInstance(context: Context): DataStore { + return instance ?: synchronized(this) { + instance ?: create(context.applicationContext).also { instance = it } + } + } + + private fun create(appContext: Context): DataStore { + val streamingAead = CredentialsTinkManager.getOrCreateStreamingAead(appContext) + val serializer = CredentialsSerializer(streamingAead) + + return DataStoreFactory.create( + serializer = serializer, + produceFile = { + File(appContext.filesDir, "datastore/${WalletConstants.CREDENTIALS_DATASTORE_FILE}") + }, + corruptionHandler = ReplaceFileCorruptionHandler { Credentials.getDefaultInstance() }, + scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) + ) + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsSerializer.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsSerializer.kt new file mode 100644 index 0000000..ddd58dd --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsSerializer.kt @@ -0,0 +1,53 @@ +package com.bitcointxoko.gudariwallet.security + +import android.util.Log +import androidx.datastore.core.CorruptionException +import androidx.datastore.core.Serializer +import com.google.crypto.tink.StreamingAead +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import java.io.InputStream +import java.io.OutputStream + +class CredentialsSerializer( + private val streamingAead: StreamingAead +) : Serializer { + private val TAG = "CredentialsSerializer" + + override val defaultValue: Credentials = Credentials.getDefaultInstance() + + override suspend fun readFrom(input: InputStream): Credentials = + withContext(Dispatchers.IO) { + try { + val result = Credentials.parseFrom( + streamingAead.newDecryptingStream(input, AAD) + ) + Log.d(TAG, "Credentials decrypted successfully — isOnboarded: ${result.isOnboarded}") + result + } catch (e: Exception) { + Log.e(TAG, "Decryption failed — DataStore may be corrupt or key rotated", e) + throw CorruptionException("Cannot read/decrypt credentials DataStore", e) + } + } + + + override suspend fun writeTo(t: Credentials, output: OutputStream): Unit = + withContext(Dispatchers.IO) { + try { + val encryptingStream = streamingAead.newEncryptingStream(output, AAD) + t.writeTo(encryptingStream) // returns Int — discarded by Unit return type + encryptingStream.close() + Log.d(TAG, "Credentials encrypted and written — isOnboarded: ${t.isOnboarded}, " + + "hasInvoiceKey: ${t.invoiceKey.isNotBlank()}, " + + "hasAdminKey: ${t.adminKey.isNotBlank()}, " + + "hasBaseUrl: ${t.baseUrl.isNotBlank()}") + } catch (e: Exception) { + Log.e(TAG, "Encryption/write failed", e) + throw e + } + } + + companion object { + private val AAD = "credentials_store".toByteArray(Charsets.UTF_8) + } +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsTinkManager.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsTinkManager.kt new file mode 100644 index 0000000..59dc11a --- /dev/null +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/security/CredentialsTinkManager.kt @@ -0,0 +1,45 @@ +package com.bitcointxoko.gudariwallet.security + +import android.content.Context +import android.util.Log +import com.bitcointxoko.gudariwallet.util.WalletConstants +import com.google.crypto.tink.StreamingAead +import com.google.crypto.tink.integration.android.AndroidKeysetManager +import com.google.crypto.tink.streamingaead.StreamingAeadConfig +import com.google.crypto.tink.KeyTemplates + +/** + * Manages the Tink keyset used to encrypt the credentials DataStore file. + * + * The keyset itself is stored in SharedPreferences, encrypted under an + * Android Keystore master key — so the key material never leaves secure hardware. + */ +object CredentialsTinkManager { + private const val TAG = "CredentialsTinkManager" + + + init { + // Register all StreamingAead primitives once at class load time + StreamingAeadConfig.register() + Log.d(TAG, "StreamingAeadConfig registered") + } + + fun getOrCreateStreamingAead(context: Context): StreamingAead { + return try { + val handle = AndroidKeysetManager.Builder() + .withSharedPref(context, WalletConstants.TINK_KEYSET_KEY, WalletConstants.TINK_KEYSET_PREF) + .withKeyTemplate(KeyTemplates.get("AES256_GCM_HKDF_4KB")) + .withMasterKeyUri(WalletConstants.TINK_MASTER_KEY_URI) + .build() + .keysetHandle + + Log.i(TAG, "Keyset loaded — key count: ${handle.size()}, primary key ID: ${handle.primary.id}") + handle.getPrimitive(StreamingAead::class.java).also { + Log.i(TAG, "StreamingAead primitive acquired successfully") + } + } catch (e: Exception) { + Log.e(TAG, "Failed to initialise StreamingAead — Keystore may be unavailable", e) + throw e + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretManager.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretManager.kt deleted file mode 100644 index 8fcfba9..0000000 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretManager.kt +++ /dev/null @@ -1,58 +0,0 @@ -package com.bitcointxoko.gudariwallet.security - -import android.security.keystore.KeyGenParameterSpec -import android.security.keystore.KeyProperties -import java.security.KeyStore -import javax.crypto.Cipher -import javax.crypto.KeyGenerator -import javax.crypto.SecretKey -import javax.crypto.spec.GCMParameterSpec -import android.util.Base64 - -object SecretManager { - private const val KEY_ALIAS = "lnbits_api_key" - private const val KEYSTORE_PROVIDER = "AndroidKeyStore" - private const val TRANSFORMATION = "AES/GCM/NoPadding" - private const val GCM_TAG_LENGTH = 128 - - /** Generate (or retrieve) the AES key in the Keystore */ - private fun getOrCreateKey(): SecretKey { - val keyStore = KeyStore.getInstance(KEYSTORE_PROVIDER).apply { load(null) } - keyStore.getKey(KEY_ALIAS, null)?.let { return it as SecretKey } - - val keyGen = KeyGenerator.getInstance(KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_PROVIDER) - keyGen.init( - KeyGenParameterSpec.Builder( - KEY_ALIAS, - KeyProperties.PURPOSE_ENCRYPT or KeyProperties.PURPOSE_DECRYPT - ) - .setBlockModes(KeyProperties.BLOCK_MODE_GCM) - .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_NONE) - .setKeySize(256) - // Require device lock screen to be set up - .setUserAuthenticationRequired(false) - .build() - ) - return keyGen.generateKey() - } - - /** Encrypt a plaintext string; returns Base64(IV + ciphertext) */ - fun encrypt(plaintext: String): String { - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.ENCRYPT_MODE, getOrCreateKey()) - val iv = cipher.iv - val ciphertext = cipher.doFinal(plaintext.toByteArray(Charsets.UTF_8)) - val combined = iv + ciphertext - return Base64.encodeToString(combined, Base64.DEFAULT) - } - - /** Decrypt a Base64(IV + ciphertext) string */ - fun decrypt(encoded: String): String { - val combined = Base64.decode(encoded, Base64.DEFAULT) - val iv = combined.copyOfRange(0, 12) - val ciphertext = combined.copyOfRange(12, combined.size) - val cipher = Cipher.getInstance(TRANSFORMATION) - cipher.init(Cipher.DECRYPT_MODE, getOrCreateKey(), GCMParameterSpec(GCM_TAG_LENGTH, iv)) - return String(cipher.doFinal(ciphertext), Charsets.UTF_8) - } -} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt index 709a0e4..0e4ef27 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/security/SecretStore.kt @@ -1,46 +1,61 @@ package com.bitcointxoko.gudariwallet.security import android.content.Context -import com.bitcointxoko.gudariwallet.util.WalletConstants +import android.util.Log +import androidx.datastore.core.DataStore +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map - -// Interface — lets you swap in a FakeSecretStore in tests interface SecretStore { - val invoiceKey: String - val adminKey: String - val baseUrl: String - val isOnboarded: Boolean - fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) + /** Full credentials as a Flow — collect in ViewModels for reactive UI. */ + val credentials: Flow + /** One-shot suspend accessors — safe to call from any suspend function. */ + suspend fun invoiceKey(): String + suspend fun adminKey(): String + suspend fun baseUrl(): String + suspend fun isOnboarded(): Boolean + + suspend fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) } class EncryptedSecretStore(context: Context) : SecretStore { + private val TAG = "EncryptedSecretStore" - private val prefs = context.getSharedPreferences(WalletConstants.PREFS_NAME, Context.MODE_PRIVATE) + private val dataStore: DataStore = + CredentialsDataStore.getInstance(context) - override val invoiceKey: String - get() = decryptOrEmpty(WalletConstants.PREFS_INVOICE_KEY_ENC) + override val credentials: Flow + get() = dataStore.data - override val adminKey: String - get() = decryptOrEmpty(WalletConstants.PREFS_ADMIN_KEY_ENC) + override suspend fun invoiceKey(): String = + dataStore.data.map { it.invoiceKey }.first() - override val baseUrl: String - get() = prefs.getString(WalletConstants.PREFS_BASE_URL, "").orEmpty() + override suspend fun adminKey(): String = + dataStore.data.map { it.adminKey }.first() - override val isOnboarded: Boolean - get() = prefs.getBoolean(WalletConstants.PREFS_ONBOARDED, false) + override suspend fun baseUrl(): String = + dataStore.data.map { it.baseUrl }.first() - override fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) { - prefs.edit() - .putString(WalletConstants.PREFS_BASE_URL, baseUrl) - .putString(WalletConstants.PREFS_INVOICE_KEY_ENC, SecretManager.encrypt(invoiceKey)) - .putString(WalletConstants.PREFS_ADMIN_KEY_ENC, SecretManager.encrypt(adminKey)) - .putBoolean(WalletConstants.PREFS_ONBOARDED, true) - .apply() + override suspend fun isOnboarded(): Boolean = + dataStore.data.map { it.isOnboarded }.first() + + override suspend fun saveCredentials( + baseUrl: String, + invoiceKey: String, + adminKey: String + ) { + Log.i(TAG, "Saving credentials — baseUrl length: ${baseUrl.length}, " + + "invoiceKey length: ${invoiceKey.length}, adminKey length: ${adminKey.length}") + dataStore.updateData { current -> + current.toBuilder() + .setBaseUrl(baseUrl) + .setInvoiceKey(invoiceKey) + .setAdminKey(adminKey) + .setIsOnboarded(true) + .build() + } + Log.i(TAG, "Credentials saved and DataStore updated") } - private fun decryptOrEmpty(key: String): String { - val enc = prefs.getString(key, "").orEmpty() - if (enc.isBlank()) return "" - return runCatching { SecretManager.decrypt(enc) }.getOrDefault("") - } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt index 46e30ab..be4db18 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/service/WalletNotificationService.kt @@ -118,7 +118,7 @@ class WalletNotificationService : Service() { // Step 2: Open the WebSocket. If already open (e.g. service restarted // by Android after a kill), close the old one first. - openWebSocket() + serviceScope.launch { openWebSocket() } // START_REDELIVER_INTENT: if Android kills the service under memory // pressure, it will restart it and re-deliver the last intent. @@ -141,19 +141,21 @@ class WalletNotificationService : Service() { // ── WebSocket ───────────────────────────────────────────────────────────── - private fun openWebSocket() { + private suspend fun openWebSocket() { val secrets = EncryptedSecretStore(applicationContext) + val invoiceKey = secrets.invoiceKey() + val baseUrl = secrets.baseUrl() - if (secrets.invoiceKey.isBlank() || secrets.baseUrl.isBlank()) { + if (invoiceKey.isBlank() || baseUrl.isBlank()) { Log.e(TAG, "Credentials not available — cannot open WebSocket") stopSelf() return } - val wsUrl = secrets.baseUrl + val wsUrl = baseUrl .replace("https://", "wss://") .replace("http://", "ws://") - .trimEnd('/') + "/api/v1/ws/${secrets.invoiceKey}" + .trimEnd('/') + "/api/v1/ws/${invoiceKey}" Log.d(TAG, "Opening WebSocket: $wsUrl") diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt index 0afa419..e65b5e5 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/OnboardingScreen.kt @@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.text.input.* import androidx.compose.ui.unit.dp import com.bitcointxoko.gudariwallet.security.SecretStore +import kotlinx.coroutines.launch @Composable fun OnboardingScreen( @@ -23,6 +24,8 @@ fun OnboardingScreen( var adminKey by remember { mutableStateOf("") } var adminVisible by remember { mutableStateOf(false) } var error by remember { mutableStateOf(null) } + val scope = rememberCoroutineScope() + Column( modifier = Modifier @@ -96,8 +99,10 @@ fun OnboardingScreen( error = "All fields are required." return@Button } - secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) - onComplete() + scope.launch { + secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) + onComplete() + } }, modifier = Modifier.fillMaxWidth() ) { diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt index f141686..65a3bdb 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/ui/WalletViewModelFactory.kt @@ -19,13 +19,11 @@ import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel -import kotlinx.coroutines.flow.MutableStateFlow class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { override fun create(modelClass: Class): T { val secrets = EncryptedSecretStore(app) - val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" } - val api = LNbitsClient.create(baseUrl) + val api = LNbitsClient.create(secrets) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app) val lnurlCache = LnurlCacheRepository(app) val paymentCache = PaymentCacheRepository(app) diff --git a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt index b2b2f3f..ecf42de 100644 --- a/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt +++ b/app/src/main/java/com/bitcointxoko/gudariwallet/util/WalletConstants.kt @@ -2,6 +2,15 @@ package com.bitcointxoko.gudariwallet.util object WalletConstants { + // ── Credentials DataStore (Tink-encrypted) ────────────────────────────── + const val CREDENTIALS_DATASTORE_FILE = "credentials.pb" + + // Tink keyset — stored in its own SharedPreferences file, encrypted + // under the Android Keystore master key. Separate from wallet_prefs. + const val TINK_KEYSET_PREF = "credentials_tink_keyset_prefs" + const val TINK_KEYSET_KEY = "credentials_tink_keyset" + const val TINK_MASTER_KEY_URI = "android-keystore://credentials_master_key" + // ── Unit conversion ─────────────────────────────────────────────────────── const val MSAT_PER_SAT = 1_000L // millisatoshis per satoshi @@ -10,13 +19,6 @@ object WalletConstants { const val WS_MAX_BACKOFF_MS = 16_000L // cap — never wait longer than this const val WS_MAX_ATTEMPTS = 5 // give up after 5 failed reconnects - // ── SharedPreferences ───────────────────────────────────────────────────── - const val PREFS_NAME = "lnbits_prefs" - const val PREFS_BASE_URL = "base_url" - const val PREFS_INVOICE_KEY_ENC = "invoice_key_enc" - const val PREFS_ADMIN_KEY_ENC = "admin_key_enc" - const val PREFS_ONBOARDED = "onboarded" - // ── Network ─────────────────────────────────────────────────────────────── const val CONNECT_TIMEOUT_S = 15L const val READ_TIMEOUT_S = 30L diff --git a/app/src/main/proto/credentials.proto b/app/src/main/proto/credentials.proto new file mode 100644 index 0000000..aebcd61 --- /dev/null +++ b/app/src/main/proto/credentials.proto @@ -0,0 +1,12 @@ +syntax = "proto3"; + +option java_package = "com.bitcointxoko.gudariwallet.security"; +option java_multiple_files = true; +option java_outer_classname = "CredentialsProto"; + +message Credentials { + string base_url = 1; + string invoice_key = 2; + string admin_key = 3; + bool is_onboarded = 4; +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index e8b49a2..fb16f15 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -22,6 +22,9 @@ appcompat = "1.7.0" ksp = "2.2.10-2.0.2" room = "2.7.1" datastore = "1.2.1" +protobuf = "4.26.1" +protobufPlugin = "0.10.0" +tink = "1.13.0" [libraries] androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } @@ -57,10 +60,14 @@ room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = " room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } androidx-datastore-preferences = { group = "androidx.datastore", name = "datastore-preferences", version.ref = "datastore" } +datastore-proto = { group = "androidx.datastore", name = "datastore", version.ref = "datastore" } +protobuf-kotlin-lite = { group = "com.google.protobuf", name = "protobuf-kotlin-lite", version.ref = "protobuf" } +protobuf-protoc = { group = "com.google.protobuf", name = "protoc", version.ref = "protobuf" } +tink-android = { group = "com.google.crypto.tink", name = "tink-android", version.ref = "tink" } [plugins] android-application = { id = "com.android.application", version.ref = "agp" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } - +protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }