feat: migrate secret management to datastore proto and tink

This commit is contained in:
2026-06-02 18:15:22 +02:00
parent 2be68d38a5
commit e8b3f898e8
16 changed files with 328 additions and 128 deletions
+6
View File
@@ -1,8 +1,14 @@
<component name="ProjectDictionaryState"> <component name="ProjectDictionaryState">
<dictionary name="project"> <dictionary name="project">
<words> <words>
<w>Aead</w>
<w>Nbits</w>
<w>Tink</w>
<w>amboss</w> <w>amboss</w>
<w>hkdf</w>
<w>lnbc</w> <w>lnbc</w>
<w>lnbits</w>
<w>lnurlp</w>
<w>msat</w> <w>msat</w>
<w>satoshis</w> <w>satoshis</w>
<w>sats</w> <w>sats</w>
+40 -1
View File
@@ -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 { plugins {
alias(libs.plugins.android.application) alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.compose) alias(libs.plugins.kotlin.compose)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
alias(libs.plugins.protobuf)
} }
android { android {
@@ -39,6 +49,13 @@ android {
compose = true compose = true
buildConfig = true buildConfig = true
} }
sourceSets {
getByName("main") {
proto {
srcDir("src/main/proto")
}
}
}
} }
dependencies { dependencies {
@@ -72,6 +89,11 @@ dependencies {
implementation(libs.androidx.lifecycle.viewmodel.ktx) implementation(libs.androidx.lifecycle.viewmodel.ktx)
implementation(libs.androidx.lifecycle.viewmodel.compose) implementation(libs.androidx.lifecycle.viewmodel.compose)
// DataStore + Proto + Tink
implementation(libs.datastore.proto)
implementation(libs.protobuf.kotlin.lite)
implementation(libs.tink.android)
// DataStore // DataStore
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
@@ -99,5 +121,22 @@ dependencies {
// animations // animations
implementation(libs.androidx.compose.animation) implementation(libs.androidx.compose.animation)
}
protobuf {
protoc {
artifact = libs.protobuf.protoc.get().toString()
}
generateProtoTasks {
all().configureEach { // no "task ->" here
builtins {
id("java") {
option("lite")
}
id("kotlin") {
option("lite")
}
}
}
}
} }
@@ -3,7 +3,6 @@ package com.bitcointxoko.gudariwallet
import android.Manifest import android.Manifest
import android.content.ActivityNotFoundException import android.content.ActivityNotFoundException
import android.content.Intent import android.content.Intent
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.PowerManager import android.os.PowerManager
@@ -17,6 +16,9 @@ import androidx.appcompat.app.AppCompatActivity
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.core.content.edit import androidx.core.content.edit
import androidx.core.net.toUri 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.security.EncryptedSecretStore
import com.bitcointxoko.gudariwallet.service.WalletNotificationService import com.bitcointxoko.gudariwallet.service.WalletNotificationService
import com.bitcointxoko.gudariwallet.ui.OnboardingScreen 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.NotificationHelper
import com.bitcointxoko.gudariwallet.util.IntentUtils import com.bitcointxoko.gudariwallet.util.IntentUtils
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
class MainActivity : AppCompatActivity() { class MainActivity : AppCompatActivity() {
@@ -57,8 +61,13 @@ class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
secretStore = EncryptedSecretStore(applicationContext) 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 // Must be first — before any notification is posted
NotificationHelper.createChannels(applicationContext) NotificationHelper.createChannels(applicationContext)
@@ -72,7 +81,10 @@ class MainActivity : AppCompatActivity() {
setContent { setContent {
GudariWalletTheme { 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) { if (isOnboarded) {
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
@@ -80,20 +92,20 @@ class MainActivity : AppCompatActivity() {
requestNotificationPermissionIfNeeded() requestNotificationPermissionIfNeeded()
promptBatteryOptimizationIfNeeded() promptBatteryOptimizationIfNeeded()
} }
// Pass pendingPaymentHash so WalletScreen can consume it
WalletScreen( WalletScreen(
vm = vm, vm = vm,
pendingPaymentHash = pendingPaymentHash, pendingPaymentHash = pendingPaymentHash,
pendingPaymentUri = pendingPaymentUri pendingPaymentUri = pendingPaymentUri
) )
} else { } else {
OnboardingScreen( OnboardingScreen(
secretStore = secretStore, 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 // Called when the activity is already running and a notification is tapped
@@ -1,7 +1,11 @@
package com.bitcointxoko.gudariwallet.api package com.bitcointxoko.gudariwallet.api
import com.bitcointxoko.gudariwallet.BuildConfig import com.bitcointxoko.gudariwallet.BuildConfig
import com.bitcointxoko.gudariwallet.security.SecretStore
import com.bitcointxoko.gudariwallet.util.WalletConstants import com.bitcointxoko.gudariwallet.util.WalletConstants
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
@@ -25,10 +29,14 @@ object LNbitsClient {
} }
.build() .build()
fun create(baseUrl: String): LNbitsApi { fun create(secrets: SecretStore): LNbitsApi {
val url = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/" val baseUrl = runBlocking(Dispatchers.IO) {
secrets.credentials.first().baseUrl
}.ifBlank { "https://placeholder.invalid" }
val normalizedUrl = if (baseUrl.endsWith("/")) baseUrl else "$baseUrl/"
return Retrofit.Builder() return Retrofit.Builder()
.baseUrl(url) .baseUrl(normalizedUrl)
.client(httpClient) .client(httpClient)
.addConverterFactory(GsonConverterFactory.create(GsonProvider.gson)) .addConverterFactory(GsonConverterFactory.create(GsonProvider.gson))
.build() .build()
@@ -18,7 +18,7 @@ class LNbitsWalletRepository(
// ── Balance ─────────────────────────────────────────────────────────────── // ── Balance ───────────────────────────────────────────────────────────────
override suspend fun getBalance(): WalletBalance { 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) return WalletBalance(sats = response.balance / WalletConstants.MSAT_PER_SAT)
} }
@@ -26,7 +26,7 @@ class LNbitsWalletRepository(
override suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice { override suspend fun createInvoice(amountSats: Long, memo: String): CreatedInvoice {
val response = api.createInvoice( val response = api.createInvoice(
secrets.invoiceKey, secrets.invoiceKey(),
CreateInvoiceRequest( CreateInvoiceRequest(
out = false, out = false,
amount = amountSats, amount = amountSats,
@@ -41,13 +41,13 @@ class LNbitsWalletRepository(
} }
override suspend fun isPaymentReceived(paymentHash: String): Boolean { override suspend fun isPaymentReceived(paymentHash: String): Boolean {
return api.checkPayment(secrets.invoiceKey, paymentHash).paid return api.checkPayment(secrets.invoiceKey(), paymentHash).paid
} }
// ── Send ────────────────────────────────────────────────────────────────── // ── Send ──────────────────────────────────────────────────────────────────
override suspend fun decodeBolt11(bolt11: String): DecodedBolt11 { 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 val amountSats = response.amountMsat
?.takeIf { it > 0L } ?.takeIf { it > 0L }
?.div(WalletConstants.MSAT_PER_SAT) ?.div(WalletConstants.MSAT_PER_SAT)
@@ -64,7 +64,7 @@ class LNbitsWalletRepository(
override suspend fun scanLnurl(code: String): ScannedLnurl { override suspend fun scanLnurl(code: String): ScannedLnurl {
val r = api.lnurlScan(secrets.invoiceKey, code) val r = api.lnurlScan(secrets.invoiceKey(), code)
return ScannedLnurl( return ScannedLnurl(
tag = r.tag, tag = r.tag,
callback = r.callback, callback = r.callback,
@@ -169,7 +169,7 @@ class LNbitsWalletRepository(
override suspend fun payBolt11(bolt11: String): PaymentConfirmation { override suspend fun payBolt11(bolt11: String): PaymentConfirmation {
val response = api.payInvoice( val response = api.payInvoice(
secrets.adminKey, secrets.adminKey(),
PayInvoiceRequest(out = true, bolt11 = bolt11) PayInvoiceRequest(out = true, bolt11 = bolt11)
) )
return PaymentConfirmation(paymentHash = response.paymentHash) return PaymentConfirmation(paymentHash = response.paymentHash)
@@ -182,7 +182,7 @@ class LNbitsWalletRepository(
comment: String? comment: String?
): PaymentConfirmation { ): PaymentConfirmation {
val response = api.payLnurl( val response = api.payLnurl(
secrets.adminKey, secrets.adminKey(),
PayLnurlRequest( PayLnurlRequest(
res = LnurlPayRes( res = LnurlPayRes(
tag = rawRes.tag, tag = rawRes.tag,
@@ -220,10 +220,10 @@ class LNbitsWalletRepository(
} }
override suspend fun getLightningAddress(): String? { 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 val username = links.firstOrNull { !it.username.isNullOrBlank() }?.username
?: return null ?: return null
val domain = secrets.baseUrl val domain = secrets.baseUrl()
.removePrefix("https://") .removePrefix("https://")
.removePrefix("http://") .removePrefix("http://")
.trimEnd('/') .trimEnd('/')
@@ -246,10 +246,10 @@ class LNbitsWalletRepository(
// ── History ─────────────────────────────────────────────────────────────── // ── History ───────────────────────────────────────────────────────────────
override suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord> { override suspend fun getPayments(offset: Int, limit: Int): List<PaymentRecord> {
return api.getPayments(secrets.invoiceKey, limit, offset) return api.getPayments(secrets.invoiceKey(), limit, offset)
} }
override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse { override suspend fun getPaymentDetail(checkingId: String): PaymentDetailResponse {
return api.getPaymentDetail(secrets.invoiceKey, checkingId) return api.getPaymentDetail(secrets.invoiceKey(), checkingId)
} }
} }
@@ -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<Credentials>? = null
fun getInstance(context: Context): DataStore<Credentials> {
return instance ?: synchronized(this) {
instance ?: create(context.applicationContext).also { instance = it }
}
}
private fun create(appContext: Context): DataStore<Credentials> {
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())
)
}
}
@@ -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<Credentials> {
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)
}
}
@@ -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
}
}
}
@@ -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)
}
}
@@ -1,46 +1,61 @@
package com.bitcointxoko.gudariwallet.security package com.bitcointxoko.gudariwallet.security
import android.content.Context 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 { interface SecretStore {
val invoiceKey: String /** Full credentials as a Flow — collect in ViewModels for reactive UI. */
val adminKey: String val credentials: Flow<Credentials>
val baseUrl: String
val isOnboarded: Boolean
fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String)
/** 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 { class EncryptedSecretStore(context: Context) : SecretStore {
private val TAG = "EncryptedSecretStore"
private val prefs = context.getSharedPreferences(WalletConstants.PREFS_NAME, Context.MODE_PRIVATE) private val dataStore: DataStore<Credentials> =
CredentialsDataStore.getInstance(context)
override val invoiceKey: String override val credentials: Flow<Credentials>
get() = decryptOrEmpty(WalletConstants.PREFS_INVOICE_KEY_ENC) get() = dataStore.data
override val adminKey: String override suspend fun invoiceKey(): String =
get() = decryptOrEmpty(WalletConstants.PREFS_ADMIN_KEY_ENC) dataStore.data.map { it.invoiceKey }.first()
override val baseUrl: String override suspend fun adminKey(): String =
get() = prefs.getString(WalletConstants.PREFS_BASE_URL, "").orEmpty() dataStore.data.map { it.adminKey }.first()
override val isOnboarded: Boolean override suspend fun baseUrl(): String =
get() = prefs.getBoolean(WalletConstants.PREFS_ONBOARDED, false) dataStore.data.map { it.baseUrl }.first()
override fun saveCredentials(baseUrl: String, invoiceKey: String, adminKey: String) { override suspend fun isOnboarded(): Boolean =
prefs.edit() dataStore.data.map { it.isOnboarded }.first()
.putString(WalletConstants.PREFS_BASE_URL, baseUrl)
.putString(WalletConstants.PREFS_INVOICE_KEY_ENC, SecretManager.encrypt(invoiceKey)) override suspend fun saveCredentials(
.putString(WalletConstants.PREFS_ADMIN_KEY_ENC, SecretManager.encrypt(adminKey)) baseUrl: String,
.putBoolean(WalletConstants.PREFS_ONBOARDED, true) invoiceKey: String,
.apply() adminKey: String
} ) {
private fun decryptOrEmpty(key: String): String { Log.i(TAG, "Saving credentials — baseUrl length: ${baseUrl.length}, " +
val enc = prefs.getString(key, "").orEmpty() "invoiceKey length: ${invoiceKey.length}, adminKey length: ${adminKey.length}")
if (enc.isBlank()) return "" dataStore.updateData { current ->
return runCatching { SecretManager.decrypt(enc) }.getOrDefault("") current.toBuilder()
.setBaseUrl(baseUrl)
.setInvoiceKey(invoiceKey)
.setAdminKey(adminKey)
.setIsOnboarded(true)
.build()
}
Log.i(TAG, "Credentials saved and DataStore updated")
} }
} }
@@ -118,7 +118,7 @@ class WalletNotificationService : Service() {
// Step 2: Open the WebSocket. If already open (e.g. service restarted // Step 2: Open the WebSocket. If already open (e.g. service restarted
// by Android after a kill), close the old one first. // by Android after a kill), close the old one first.
openWebSocket() serviceScope.launch { openWebSocket() }
// START_REDELIVER_INTENT: if Android kills the service under memory // START_REDELIVER_INTENT: if Android kills the service under memory
// pressure, it will restart it and re-deliver the last intent. // pressure, it will restart it and re-deliver the last intent.
@@ -141,19 +141,21 @@ class WalletNotificationService : Service() {
// ── WebSocket ───────────────────────────────────────────────────────────── // ── WebSocket ─────────────────────────────────────────────────────────────
private fun openWebSocket() { private suspend fun openWebSocket() {
val secrets = EncryptedSecretStore(applicationContext) 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") Log.e(TAG, "Credentials not available — cannot open WebSocket")
stopSelf() stopSelf()
return return
} }
val wsUrl = secrets.baseUrl val wsUrl = baseUrl
.replace("https://", "wss://") .replace("https://", "wss://")
.replace("http://", "ws://") .replace("http://", "ws://")
.trimEnd('/') + "/api/v1/ws/${secrets.invoiceKey}" .trimEnd('/') + "/api/v1/ws/${invoiceKey}"
Log.d(TAG, "Opening WebSocket: $wsUrl") Log.d(TAG, "Opening WebSocket: $wsUrl")
@@ -12,6 +12,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.text.input.* import androidx.compose.ui.text.input.*
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.bitcointxoko.gudariwallet.security.SecretStore import com.bitcointxoko.gudariwallet.security.SecretStore
import kotlinx.coroutines.launch
@Composable @Composable
fun OnboardingScreen( fun OnboardingScreen(
@@ -23,6 +24,8 @@ fun OnboardingScreen(
var adminKey by remember { mutableStateOf("") } var adminKey by remember { mutableStateOf("") }
var adminVisible by remember { mutableStateOf(false) } var adminVisible by remember { mutableStateOf(false) }
var error by remember { mutableStateOf<String?>(null) } var error by remember { mutableStateOf<String?>(null) }
val scope = rememberCoroutineScope()
Column( Column(
modifier = Modifier modifier = Modifier
@@ -96,8 +99,10 @@ fun OnboardingScreen(
error = "All fields are required." error = "All fields are required."
return@Button return@Button
} }
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey) scope.launch {
onComplete() secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
onComplete()
}
}, },
modifier = Modifier.fillMaxWidth() modifier = Modifier.fillMaxWidth()
) { ) {
@@ -19,13 +19,11 @@ import com.bitcointxoko.gudariwallet.ui.clipboard.ClipboardViewModel
import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel import com.bitcointxoko.gudariwallet.ui.fiat.FiatViewModel
import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel import com.bitcointxoko.gudariwallet.ui.receive.ReceiveViewModel
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
import kotlinx.coroutines.flow.MutableStateFlow
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory { class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
override fun <T : ViewModel> create(modelClass: Class<T>): T { override fun <T : ViewModel> create(modelClass: Class<T>): T {
val secrets = EncryptedSecretStore(app) val secrets = EncryptedSecretStore(app)
val baseUrl = secrets.baseUrl.ifBlank { "https://placeholder.invalid" } val api = LNbitsClient.create(secrets)
val api = LNbitsClient.create(baseUrl)
val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app) val nodeAliasSvc = NodeAliasService(apiClient = NodeAliasClient(), context = app)
val lnurlCache = LnurlCacheRepository(app) val lnurlCache = LnurlCacheRepository(app)
val paymentCache = PaymentCacheRepository(app) val paymentCache = PaymentCacheRepository(app)
@@ -2,6 +2,15 @@ package com.bitcointxoko.gudariwallet.util
object WalletConstants { 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 ─────────────────────────────────────────────────────── // ── Unit conversion ───────────────────────────────────────────────────────
const val MSAT_PER_SAT = 1_000L // millisatoshis per satoshi 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_BACKOFF_MS = 16_000L // cap — never wait longer than this
const val WS_MAX_ATTEMPTS = 5 // give up after 5 failed reconnects 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 ─────────────────────────────────────────────────────────────── // ── Network ───────────────────────────────────────────────────────────────
const val CONNECT_TIMEOUT_S = 15L const val CONNECT_TIMEOUT_S = 15L
const val READ_TIMEOUT_S = 30L const val READ_TIMEOUT_S = 30L
+12
View File
@@ -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;
}
+8 -1
View File
@@ -22,6 +22,9 @@ appcompat = "1.7.0"
ksp = "2.2.10-2.0.2" ksp = "2.2.10-2.0.2"
room = "2.7.1" room = "2.7.1"
datastore = "1.2.1" datastore = "1.2.1"
protobuf = "4.26.1"
protobufPlugin = "0.10.0"
tink = "1.13.0"
[libraries] [libraries]
androidx-biometric = { module = "androidx.biometric:biometric", version.ref = "biometric" } 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-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" }
room-compiler = { group = "androidx.room", name = "room-compiler", 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" } 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] [plugins]
android-application = { id = "com.android.application", version.ref = "agp" } android-application = { id = "com.android.application", version.ref = "agp" }
kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" } kotlin-android = { id = "org.jetbrains.kotlin.android", version.ref = "kotlin" }
kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } kotlin-compose = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
protobuf = { id = "com.google.protobuf", version.ref = "protobufPlugin" }