feat: migrate secret management to datastore proto and tink
This commit is contained in:
Generated
+6
@@ -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
@@ -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
|
||||||
|
) {
|
||||||
|
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("")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
+7
-5
@@ -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
|
||||||
|
|||||||
@@ -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"
|
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" }
|
||||||
|
|||||||
Reference in New Issue
Block a user