feat: migrate secret management to datastore proto and tink
This commit is contained in:
Generated
+6
@@ -1,8 +1,14 @@
|
||||
<component name="ProjectDictionaryState">
|
||||
<dictionary name="project">
|
||||
<words>
|
||||
<w>Aead</w>
|
||||
<w>Nbits</w>
|
||||
<w>Tink</w>
|
||||
<w>amboss</w>
|
||||
<w>hkdf</w>
|
||||
<w>lnbc</w>
|
||||
<w>lnbits</w>
|
||||
<w>lnurlp</w>
|
||||
<w>msat</w>
|
||||
<w>satoshis</w>
|
||||
<w>sats</w>
|
||||
|
||||
+40
-1
@@ -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)
|
||||
|
||||
}
|
||||
|
||||
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.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,7 +92,6 @@ class MainActivity : AppCompatActivity() {
|
||||
requestNotificationPermissionIfNeeded()
|
||||
promptBatteryOptimizationIfNeeded()
|
||||
}
|
||||
// Pass pendingPaymentHash so WalletScreen can consume it
|
||||
WalletScreen(
|
||||
vm = vm,
|
||||
pendingPaymentHash = pendingPaymentHash,
|
||||
@@ -89,11 +100,12 @@ class MainActivity : AppCompatActivity() {
|
||||
} 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
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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<PaymentRecord> {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
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<Credentials>
|
||||
|
||||
/** 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<Credentials> =
|
||||
CredentialsDataStore.getInstance(context)
|
||||
|
||||
override val invoiceKey: String
|
||||
get() = decryptOrEmpty(WalletConstants.PREFS_INVOICE_KEY_ENC)
|
||||
override val credentials: Flow<Credentials>
|
||||
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()
|
||||
}
|
||||
private fun decryptOrEmpty(key: String): String {
|
||||
val enc = prefs.getString(key, "").orEmpty()
|
||||
if (enc.isBlank()) return ""
|
||||
return runCatching { SecretManager.decrypt(enc) }.getOrDefault("")
|
||||
Log.i(TAG, "Credentials saved and DataStore updated")
|
||||
}
|
||||
}
|
||||
+7
-5
@@ -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")
|
||||
|
||||
|
||||
@@ -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<String?>(null) }
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
@@ -96,8 +99,10 @@ fun OnboardingScreen(
|
||||
error = "All fields are required."
|
||||
return@Button
|
||||
}
|
||||
scope.launch {
|
||||
secretStore.saveCredentials(baseUrl, invoiceKey, adminKey)
|
||||
onComplete()
|
||||
}
|
||||
},
|
||||
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.receive.ReceiveViewModel
|
||||
import com.bitcointxoko.gudariwallet.ui.send.SendViewModel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
|
||||
class WalletViewModelFactory(private val app: Application) : ViewModelProvider.Factory {
|
||||
override fun <T : ViewModel> create(modelClass: Class<T>): 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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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" }
|
||||
|
||||
Reference in New Issue
Block a user