feat(recovery): the key behind an identity that has no phrase, and the way to forget it

Phase 6 of docs/nsec-sign-in.md. KeyRecoveryScreen offered one backup, the
recovery phrase, whose screen reads userWallet.words out of the seed map. For
an identity signed in with a bare key there are no words, and the honest
answer that screen would give is "this device holds no phrase for the
profile". So:

KeyRecoveryScreen branches on the active identity's kind. A mnemonic identity
keeps the phrase option. A NostrSecret identity gets a nostr secret key option
in its place, routing to NostrSecretRoute, with its own status line ("you said
you stored it") and a header that no longer promises coins. The backup flags
underneath are the same two per-identity preferences the phrase screen
writes; they already mean "this identity's secret is not backed up" for
whichever secret it is.

NostrSecretScreen is RecoveryPhraseScreen with the word grid replaced by the
nsec: hidden until revealed, revealed in monospace, selectable, with a copy
action -- nobody transcribes sixty-three characters by hand -- the same two
checkboxes, hidden again on leaving. The view model reads the key from
nostr-keys.dat at reveal time, by the identity's public key, not off the
active identity, so the screen's contract -- nothing secret held longer than
it is shown -- is the phrase screen's. The disclaimer is a new string: the
old one said "...and the funds in its wallet", and this identity has no
wallet to warn about.

Forget this key. An import needs an inverse, and this is the first real "sign
out" in the app; ActiveProfileScreen's button still routes to a pending
screen, and the general case stays there, because removing a seed is a wallet
question with funds behind it. Behind a dialog that names the npub and says
the profile stays on the relays, four effects in an order that matters: the
key out of nostr-keys.dat and the identity's preference files off the disk
(IdentityWriter.forgetNostrKey, which refuses a key that is not in the file
-- a wallet's nostr key lives in the seed); the metadata entry hidden, since
the metadata store has no delete and isHidden is what the selector filters
on; the account's unsigned rows deleted (forgetLocalAccount -- the kind 0
that made it a local account and anything queued that can now never be
signed, with the requests that hang off them cascading; published events
and the profile cache stay, as anyone else's would); and only then the
caller told, which re-lists identities and clears the active one so the
navigation observer sends a null identity to startup. A failure at the first
step leaves the rest untouched -- an identity the selector lists but nothing
can open is worse than one that is still there.

Tests. IdentityWriterJvmTest runs both bare-key writers against the jvm key
store and a real directory: a key written once and refused the second time
by public key, with its preferences file created; forgetting one key leaves
the other and takes its preferences with it; a key that is not in the file is
not forgotten. Its keys are fresh per test rather than fixed, because
DataStoreManager caches each id's preferences in a companion object for the
life of the process, and a fixed key was served the UserPrefs an earlier test
had created in an earlier temp directory -- worth knowing about that cache.
NostrSecretViewModelJvmTest records the four effects and asserts their order,
and that a key that could not be removed stops the sequence at one.
ForgetLocalAccountJvmTest, against Room, asserts the account and its cascaded
requests go while an unrelated account stays.

Verified with :composeApp:compileDebugKotlinAndroid, :composeApp:jvmTest (901
tests) and :composeApp:m3Audit.

Replayed onto Mantra by docs/curated-to-mantra.md: KeyRecoveryScreen.kt: the class comment this commit rewrites is taken whole; the only base difference was the dropped rebrand capitalising the brand in one word of it.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@e3cc23ae31
This commit is contained in:
Kgothatso Ngako
2026-09-12 12:27:54 +02:00
parent 347cf26d83
commit e2295b3971
15 changed files with 1244 additions and 27 deletions

View File

@@ -595,4 +595,28 @@
<string name="it_may_be_new_or_it_may_live_on_relays_we">It may be new, or it may live on relays we do not know about. Ask again, or set one up now and publish it from here.</string>
<string name="set_up_a_profile">Set up a profile</string>
<string name="we_could_not_find_a_profile_for_this_key">We could not find a profile for this key on the relays we asked.</string>
<!-- Recovery for an identity with a key and no phrase, docs/nsec-sign-in.md, phase 6. -->
<string name="copied_the_nsec">Copied the nsec</string>
<string name="copy_nsec">Copy nsec</string>
<string name="could_not_forget_the_key_please_try_again">Could not forget the key. Please try again.</string>
<string name="could_not_unlock_your_key_please_try">Could not unlock your key. Please try again.</string>
<string name="mantra_will_delete_the_nsec_for_s_from_this">Mantra will delete the nsec for %1$s from this device. Anything not yet published is lost. The profile stays on the relays, and the key can be signed in again.</string>
<string name="display_nostr_secret_key">Display nostr secret key</string>
<string name="forget">Forget</string>
<string name="forget_this_key">Forget this key</string>
<string name="forget_this_key_question">Forget this key?</string>
<string name="i_have_saved_my_nostr_secret_key_somewhere">I have saved my nostr secret key somewhere safe.</string>
<string name="i_understand_that_if_i_lose_this_phone_and_my_key">I understand that if I lose this phone and my nostr secret key, I lose this profile.</string>
<string name="keep_this_key_safe_do_not_share_it">Keep this key safe.\nDo not share it.</string>
<string name="key_forgotten">Key forgotten</string>
<string name="no_identity_is_open_on_this_device_so_there">No profile is open on this device, so there is no key to show.</string>
<string name="nostr_secret_key">Nostr secret key</string>
<string name="remove_the_key_from_this_device_the_profile">Remove the key from this device. The profile stays on the relays.</string>
<string name="store_the_nsec_that_signs_as_this_profile">Store the nsec that signs as this profile. There is no phrase behind it: the key is the whole backup.</string>
<string name="the_nsec_is_the_key_that_signs_as_you_this">The nsec is the key that signs as you. This profile was signed in with it rather than made from a recovery phrase, so there is no phrase to write down and no wallet attached: the key is the whole of the backup.\n\nOnly you have it. Keep it private — nobody from Mantra will ever ask you for it.\n\nDo not lose it. Store it somewhere safe that is not this phone. If you lose both the phone and the key, this profile is gone for good.</string>
<string name="this_device_holds_no_key_for_the_profile">This device holds no nostr secret key for the profile that is open. If it was made from a recovery phrase, the phrase is its backup.</string>
<string name="unlocking_your_key">Unlocking your key…</string>
<string name="you_have_not_backed_up_your_nostr_secret_key">You have not backed up your nostr secret key</string>
<string name="these_are_your_keys_keep_them_safe_no_wallet">These are your keys. Keep them safe so they can keep unlocking this profile, even when you lose or change your phone.</string>
<string name="you_said_you_stored_it">You said you stored it</string>
</resources>

View File

@@ -23,6 +23,15 @@ interface UnsignedNostrEventDao {
@Upsert
suspend fun upsert(unsignedNostrEvent: UnsignedNostrEvent): Long
/**
* Every unsigned row of a key this device no longer holds: the kind 0 that makes it a
* local account, and anything queued that can now never be signed. The requests that
* hang off them cascade; published events and the profile cache do not, and stay as
* they would for anyone else's key.
*/
@Query("DELETE FROM UnsignedNostrEvent WHERE pubKey = :publicKey")
suspend fun deleteAllForPublicKey(publicKey: String): Int
@Insert
suspend fun insert(unsignedNostrEvents: List<UnsignedNostrEvent>)

View File

@@ -154,6 +154,11 @@ class DatabaseNostrRepository(
saveUnsignedNostrEvents(events.filterNot { it.kind == MetadataEvent.KIND })
}
override suspend fun forgetLocalAccount(publicKey: HexKey) {
logger.d("forgetLocalAccount: $publicKey")
database.unsignedNostrEventDao().deleteAllForPublicKey(publicKey)
}
/**
* What a key gets when it first becomes a profile here: the kind 0, a contact list
* that follows itself, and the relay lists the app reads, all pointed at the defaults.

View File

@@ -206,4 +206,42 @@ object IdentityWriter {
return WriteNostrKeyResult.Written(stored.id)
}
sealed class ForgetNostrKeyResult {
data object Forgotten : ForgetNostrKeyResult()
/** The key is not in `nostr-keys.dat`: a mnemonic identity's key lives in the seed, and removing a seed is a wallet question. */
data object NotABareKey : ForgetNostrKeyResult()
data object CannotLoadKeys : ForgetNostrKeyResult()
}
/**
* The inverse of [writeNostrKey]: removes the key from `nostr-keys.dat` and deletes
* the identity's preference files. The profile stays on the relays, and the same key
* can be signed in again. Only for a bare key -- see [ForgetNostrKeyResult.NotABareKey].
*/
suspend fun forgetNostrKey(
log: Logger,
phoenixGlobal: PhoenixGlobal,
id: WalletId,
nostrPublicKey: HexKey,
): ForgetNostrKeyResult {
val existing = NostrKeyManager.loadAndDecryptOrNull(phoenixGlobal)
if (existing == null) {
log.e("could not load the existing keys, aborting...")
return ForgetNostrKeyResult.CannotLoadKeys
}
if (!existing.containsKey(nostrPublicKey)) {
log.i("asked to forget a key that is not a bare key on this device")
return ForgetNostrKeyResult.NotABareKey
}
NostrKeyManager.writeToDisk(phoenixGlobal, EncryptedNostrKeys.encrypt(existing - nostrPublicKey))
log.i("forgot nostr key for identity=$id")
DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain).deleteNodeUserPrefs(id)
return ForgetNostrKeyResult.Forgotten
}
}

View File

@@ -73,6 +73,13 @@ interface NostrRepository {
biography: String?,
)
/**
* Stops treating [publicKey] as one of this device's own: its unsigned rows go, and
* with them the requests that hang off them. What it published, and its profile, stay
* cached as anyone else's would.
*/
suspend fun forgetLocalAccount(publicKey: HexKey)
suspend fun createNewTextNote(
publicKey: HexKey,
mentionedPublicKeys: List<HexKey>,
@@ -260,6 +267,10 @@ interface NostrRepository {
TODO("Not yet implemented")
}
override suspend fun forgetLocalAccount(publicKey: HexKey) {
TODO("Not yet implemented")
}
override suspend fun createNewTextNote(
publicKey: HexKey,
mentionedPublicKeys: List<HexKey>,

View File

@@ -16,6 +16,7 @@ import androidx.compose.material.icons.filled.AddToDrive
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.LocalHospital
import androidx.compose.material.icons.filled.Key
import androidx.compose.material.icons.filled.Spellcheck
import androidx.compose.material.icons.filled.VolunteerActivism
import androidx.compose.material.icons.filled.Warning
@@ -42,6 +43,7 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import com.vitorpamplona.quartz.nip01Core.core.HexKey
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
@@ -52,7 +54,11 @@ import mantra.composeapp.generated.resources.emergency_kit
import mantra.composeapp.generated.resources.encrypt_and_back_your_recovery_information
import mantra.composeapp.generated.resources.key_recovery
import mantra.composeapp.generated.resources.not_backed_up_yet
import mantra.composeapp.generated.resources.nostr_secret_key
import mantra.composeapp.generated.resources.recovery_phrase
import mantra.composeapp.generated.resources.store_the_nsec_that_signs_as_this_profile
import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_no_wallet
import mantra.composeapp.generated.resources.you_said_you_stored_it
import mantra.composeapp.generated.resources.these_are_your_keys_keep_them_safe_so_they
import mantra.composeapp.generated.resources.write_down_and_secure_the_12_word_phrase
import mantra.composeapp.generated.resources.yolo
@@ -61,6 +67,7 @@ import mantra.composeapp.generated.resources.you_said_you_wrote_it_down
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.extensions.hexToNpubHrp
import press.mantra.compose.ui.composable.navigation.routes.ImplementationPendingRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute
import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute
import press.mantra.compose.ui.composable.navigation.routes.Route
import press.mantra.compose.ui.composable.widgets.Decorative
@@ -71,11 +78,12 @@ import press.mantra.compose.ui.theme.spacing
/**
* Everything that can put this profile back on another phone.
*
* A mantra profile is its key: the npub that signs, and the wallet that holds
* coins, both come out of one seed that never leaves the device. There is no
* account to reset, so this screen is the only thing standing between a lost
* phone and a lost identity -- it exists to get the user to take a backup while
* they still can.
* A Mantra profile is its key: the npub that signs and, for a wallet, the coins
* it holds, both come out of one secret that never leaves the device -- twelve
* words, or a bare nostr key that was signed in with. There is no account to
* reset, so this screen is the only thing standing between a lost phone and a
* lost identity -- it exists to get the user to take a backup while they still
* can.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
@@ -133,7 +141,11 @@ fun KeyRecoveryScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
text = stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they),
// A bare-key identity has no coins to speak of.
text = when (activeIdentity?.kind) {
IdentityKind.NostrSecret -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet)
else -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they)
},
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
)
@@ -147,27 +159,55 @@ fun KeyRecoveryScreen(
}
item {
KeyRecoveryOption(
icon = Icons.Default.Spellcheck,
title = stringResource(Res.string.recovery_phrase),
description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
status = if (showBackupNotice) {
KeyRecoveryStatus(
icon = Icons.Default.Warning,
text = stringResource(Res.string.not_backed_up_yet)
)
} else {
KeyRecoveryStatus(
icon = Icons.Default.CheckCircle,
text = stringResource(Res.string.you_said_you_wrote_it_down)
)
},
onClick = {
onNavigateToRoute.invoke(RecoveryPhraseRoute)
}
)
// The backup an identity has depends on what it was made from. Twelve words
// for a wallet; for a bare key there is no phrase, and the key is the whole
// backup. Same flags underneath -- they are per identity and mean "this
// identity's secret is not backed up" for whichever secret it is.
when (activeIdentity?.kind) {
IdentityKind.NostrSecret -> KeyRecoveryOption(
icon = Icons.Default.Key,
title = stringResource(Res.string.nostr_secret_key),
description = stringResource(Res.string.store_the_nsec_that_signs_as_this_profile),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
status = if (showBackupNotice) {
KeyRecoveryStatus(
icon = Icons.Default.Warning,
text = stringResource(Res.string.not_backed_up_yet)
)
} else {
KeyRecoveryStatus(
icon = Icons.Default.CheckCircle,
text = stringResource(Res.string.you_said_you_stored_it)
)
},
onClick = {
onNavigateToRoute.invoke(NostrSecretRoute)
}
)
else -> KeyRecoveryOption(
icon = Icons.Default.Spellcheck,
title = stringResource(Res.string.recovery_phrase),
description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase),
containerColor = MaterialTheme.colorScheme.secondaryContainer,
contentColor = MaterialTheme.colorScheme.onSecondaryContainer,
status = if (showBackupNotice) {
KeyRecoveryStatus(
icon = Icons.Default.Warning,
text = stringResource(Res.string.not_backed_up_yet)
)
} else {
KeyRecoveryStatus(
icon = Icons.Default.CheckCircle,
text = stringResource(Res.string.you_said_you_wrote_it_down)
)
},
onClick = {
onNavigateToRoute.invoke(RecoveryPhraseRoute)
}
)
}
}
item {

View File

@@ -0,0 +1,490 @@
package press.mantra.compose.ui.composable
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.ContentCopy
import androidx.compose.material.icons.filled.DeleteForever
import androidx.compose.material.icons.filled.Visibility
import androidx.compose.material.icons.filled.VisibilityOff
import androidx.compose.material.icons.filled.Warning
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.Checkbox
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalClipboardManager
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.style.TextAlign
import androidx.lifecycle.viewmodel.compose.viewModel
import mantra.composeapp.generated.resources.Res
import mantra.composeapp.generated.resources.back
import mantra.composeapp.generated.resources.backup_confirmation
import mantra.composeapp.generated.resources.cancel
import mantra.composeapp.generated.resources.copied_the_nsec
import mantra.composeapp.generated.resources.copy_nsec
import mantra.composeapp.generated.resources.could_not_forget_the_key_please_try_again
import mantra.composeapp.generated.resources.could_not_unlock_your_key_please_try
import mantra.composeapp.generated.resources.mantra_will_delete_the_nsec_for_s_from_this
import mantra.composeapp.generated.resources.display_nostr_secret_key
import mantra.composeapp.generated.resources.forget
import mantra.composeapp.generated.resources.forget_this_key
import mantra.composeapp.generated.resources.forget_this_key_question
import mantra.composeapp.generated.resources.hide
import mantra.composeapp.generated.resources.i_have_saved_my_nostr_secret_key_somewhere
import mantra.composeapp.generated.resources.i_understand_that_if_i_lose_this_phone_and_my_key
import mantra.composeapp.generated.resources.keep_this_key_safe_do_not_share_it
import mantra.composeapp.generated.resources.key_forgotten
import mantra.composeapp.generated.resources.loading_preferences
import mantra.composeapp.generated.resources.lose_this_phone_before_you_do_and_the
import mantra.composeapp.generated.resources.no_identity_is_open_on_this_device_so_there
import mantra.composeapp.generated.resources.nostr_secret_key
import mantra.composeapp.generated.resources.remove_the_key_from_this_device_the_profile
import mantra.composeapp.generated.resources.the_nsec_is_the_key_that_signs_as_you_this
import mantra.composeapp.generated.resources.this_device_holds_no_key_for_the_profile
import mantra.composeapp.generated.resources.unlocking_your_key
import mantra.composeapp.generated.resources.you_have_not_backed_up_your_nostr_secret_key
import fr.acinq.phoenix.PhoenixGlobal
import kotlinx.coroutines.flow.StateFlow
import org.jetbrains.compose.resources.stringResource
import press.mantra.compose.extensions.hexToNpubHrp
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.composable.widgets.Decorative
import press.mantra.compose.ui.composable.widgets.ErrorState
import press.mantra.compose.ui.composable.widgets.LoadingDataIndicator
import press.mantra.compose.ui.composable.widgets.LocalSnackbarHostState
import press.mantra.compose.ui.composable.widgets.rememberNotifier
import press.mantra.compose.ui.theme.ConformancePreviews
import press.mantra.compose.ui.theme.readableContent
import press.mantra.compose.ui.theme.spacing
import press.mantra.compose.ui.view.model.NostrSecretViewModel
import press.mantra.compose.ui.view.state.NostrSecretUIState
/**
* The nostr secret key behind a bare-key identity, shown once and on request.
*
* `RecoveryPhraseScreen` with the word grid replaced by the nsec, for the identity kind
* that has a key and no phrase. The reveal is a separate step from opening the screen
* for the same reason: reading the key goes to the keystore, and nobody should walk past
* a phone that is quietly displaying it. Below the backup section is the one thing this
* screen has that the phrase screen does not: the way to take the key off the device.
*/
@Composable
fun NostrSecretScreen(
phoenixGlobal: PhoenixGlobal,
activeIdentityStateFlow: StateFlow<Identity?>,
nostrRepository: NostrRepository,
forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult,
hideIdentityMetadata: suspend (Identity) -> Unit,
onNavigateBack: () -> Unit,
onForgotten: () -> Unit,
) {
val viewModel: NostrSecretViewModel = viewModel(
factory = NostrSecretViewModel.factory(
phoenixGlobal = phoenixGlobal,
activeIdentityStateFlow = activeIdentityStateFlow,
nostrRepository = nostrRepository,
forgetNostrKey = forgetNostrKey,
hideIdentityMetadata = hideIdentityMetadata,
)
)
val uiState by viewModel.uiState.collectAsState()
val forgetting by viewModel.forgetting.collectAsState()
val isBareKeyIdentity by viewModel.isBareKeyIdentity.collectAsState()
val isBackupDone by viewModel.isBackupDone.collectAsState()
val isDisclaimerRead by viewModel.isDisclaimerRead.collectAsState()
val showBackupNotice by viewModel.showBackupNotice.collectAsState()
val activeIdentity by activeIdentityStateFlow.collectAsState()
// Both composable, and the handlers that show these are not: read above them.
val notify = rememberNotifier(rememberCoroutineScope())
val keyForgotten = stringResource(Res.string.key_forgotten)
NostrSecretContent(
uiState = uiState,
forgetting = forgetting,
npub = activeIdentity?.nostrPublicKey?.hexToNpubHrp(),
canForget = isBareKeyIdentity,
isBackupDone = isBackupDone,
isDisclaimerRead = isDisclaimerRead,
showBackupNotice = showBackupNotice,
onReveal = viewModel::revealNostrSecret,
onHide = viewModel::hideNostrSecret,
onBackupDoneChange = viewModel::setBackupDone,
onDisclaimerReadChange = viewModel::setDisclaimerRead,
onAskToForget = viewModel::askToForgetKey,
onCancelForget = viewModel::cancelForgetKey,
onForget = {
viewModel.forgetKey {
notify(keyForgotten)
onForgotten()
}
},
onNavigateBack = onNavigateBack,
)
}
/**
* The screen once the state and the preferences have been read. Everything the view
* model owns arrives as a value or a callback, so a preview can render any branch
* without a key on disk.
*/
@OptIn(ExperimentalMaterial3ExpressiveApi::class, ExperimentalMaterial3Api::class)
@Composable
private fun NostrSecretContent(
uiState: NostrSecretUIState,
forgetting: NostrSecretUIState.Forgetting,
npub: String?,
canForget: Boolean,
isBackupDone: Boolean?,
isDisclaimerRead: Boolean?,
showBackupNotice: Boolean,
onReveal: () -> Unit,
onHide: () -> Unit,
onBackupDoneChange: (Boolean) -> Unit,
onDisclaimerReadChange: (Boolean) -> Unit,
onAskToForget: () -> Unit,
onCancelForget: () -> Unit,
onForget: () -> Unit,
onNavigateBack: () -> Unit,
) {
if (forgetting is NostrSecretUIState.Forgetting.Confirming) {
AlertDialog(
onDismissRequest = onCancelForget,
icon = { Icon(Icons.Default.DeleteForever, contentDescription = Decorative) },
title = { Text(stringResource(Res.string.forget_this_key_question)) },
text = { Text(stringResource(Res.string.mantra_will_delete_the_nsec_for_s_from_this, npub ?: "")) },
confirmButton = {
TextButton(onClick = onForget) { Text(stringResource(Res.string.forget)) }
},
dismissButton = {
TextButton(onClick = onCancelForget) { Text(stringResource(Res.string.cancel)) }
},
)
}
Scaffold(
snackbarHost = { SnackbarHost(LocalSnackbarHostState.current) },
topBar = {
TopAppBar(
title = { Text(text = stringResource(Res.string.nostr_secret_key)) },
navigationIcon = {
IconButton(onClick = onNavigateBack) {
Icon(
Icons.AutoMirrored.Filled.ArrowBack,
contentDescription = stringResource(Res.string.back)
)
}
}
)
}
) { innerPadding ->
LazyColumn(
modifier = Modifier
.padding(innerPadding)
.readableContent()
.padding(horizontal = MaterialTheme.spacing.screenMargin),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
horizontalAlignment = Alignment.CenterHorizontally
) {
item {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(vertical = MaterialTheme.spacing.containerPadding),
text = stringResource(Res.string.the_nsec_is_the_key_that_signs_as_you_this),
style = MaterialTheme.typography.bodyMedium
)
}
if (showBackupNotice) {
item {
Card(
modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.spacing.containerPadding),
verticalAlignment = Alignment.CenterVertically
) {
Icon(Icons.Default.Warning, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Column {
Text(
text = stringResource(Res.string.you_have_not_backed_up_your_nostr_secret_key),
style = MaterialTheme.typography.titleSmall
)
Text(
text = stringResource(Res.string.lose_this_phone_before_you_do_and_the),
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.spacing.containerPadding),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap),
horizontalAlignment = Alignment.CenterHorizontally
) {
when (uiState) {
is NostrSecretUIState.Hidden -> {
Button(onClick = onReveal) {
Icon(Icons.Default.Visibility, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(text = stringResource(Res.string.display_nostr_secret_key))
}
}
is NostrSecretUIState.Revealing -> {
LoadingDataIndicator(
fillScreen = false,
text = stringResource(Res.string.unlocking_your_key)
)
}
is NostrSecretUIState.Revealed -> {
RevealedNsec(nsec = uiState.nsec)
TextButton(onClick = onHide) {
Icon(Icons.Default.VisibilityOff, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(text = stringResource(Res.string.hide))
}
}
is NostrSecretUIState.Error -> {
ErrorState(
message = when (uiState) {
is NostrSecretUIState.Error.NoActiveIdentity ->
stringResource(Res.string.no_identity_is_open_on_this_device_so_there)
is NostrSecretUIState.Error.NoKeyForThisIdentity ->
stringResource(Res.string.this_device_holds_no_key_for_the_profile)
is NostrSecretUIState.Error.KeysUnreadable ->
stringResource(Res.string.could_not_unlock_your_key_please_try)
},
// Retrying without an identity, or for one whose key is in
// the seed rather than here, cannot help.
onRetry = when (uiState) {
is NostrSecretUIState.Error.KeysUnreadable -> onReveal
else -> null
}
)
}
}
}
}
}
item {
Text(
modifier = Modifier.fillMaxWidth(),
text = stringResource(Res.string.backup_confirmation),
style = MaterialTheme.typography.titleMedium
)
}
item {
Card(modifier = Modifier.fillMaxWidth()) {
if (isBackupDone == null || isDisclaimerRead == null) {
Text(
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.spacing.containerPadding),
text = stringResource(Res.string.loading_preferences),
style = MaterialTheme.typography.bodySmall
)
} else {
BackupCheckbox(
checked = isBackupDone,
text = stringResource(Res.string.i_have_saved_my_nostr_secret_key_somewhere),
onCheckedChange = onBackupDoneChange
)
BackupCheckbox(
checked = isDisclaimerRead,
text = stringResource(Res.string.i_understand_that_if_i_lose_this_phone_and_my_key),
onCheckedChange = onDisclaimerReadChange
)
}
}
}
if (canForget) {
item {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.sectionGap))
Card(
modifier = Modifier.fillMaxWidth(),
onClick = onAskToForget,
enabled = forgetting !is NostrSecretUIState.Forgetting.InProgress,
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.errorContainer,
contentColor = MaterialTheme.colorScheme.onErrorContainer
)
) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(MaterialTheme.spacing.containerPadding),
verticalArrangement = Arrangement.spacedBy(MaterialTheme.spacing.itemGap)
) {
Row(verticalAlignment = Alignment.CenterVertically) {
Icon(Icons.Default.DeleteForever, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(
text = stringResource(Res.string.forget_this_key),
style = MaterialTheme.typography.titleMedium
)
}
Text(
text = when (forgetting) {
is NostrSecretUIState.Forgetting.Failed ->
stringResource(Res.string.could_not_forget_the_key_please_try_again)
else -> stringResource(Res.string.remove_the_key_from_this_device_the_profile)
},
style = MaterialTheme.typography.bodySmall
)
}
}
}
}
item {
Spacer(modifier = Modifier.height(MaterialTheme.spacing.containerPadding))
}
}
}
}
/** The key itself, selectable and with a copy action, since nobody transcribes sixty-three characters by hand. */
@Composable
private fun RevealedNsec(nsec: String) {
val clipboardManager = LocalClipboardManager.current
val notify = rememberNotifier(rememberCoroutineScope())
val copied = stringResource(Res.string.copied_the_nsec)
Text(
text = stringResource(Res.string.keep_this_key_safe_do_not_share_it),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center
)
SelectionContainer {
Text(
text = nsec,
style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace),
textAlign = TextAlign.Center
)
}
TextButton(
onClick = {
clipboardManager.setText(AnnotatedString(nsec))
notify(copied)
}
) {
Icon(Icons.Default.ContentCopy, contentDescription = Decorative)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(text = stringResource(Res.string.copy_nsec))
}
}
@Composable
private fun BackupCheckbox(
checked: Boolean,
text: String,
onCheckedChange: (Boolean) -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
// The whole row toggles, so the row is the target and has to carry the 48dp
// the Checkbox alone would.
.minimumInteractiveComponentSize()
.clickable { onCheckedChange(!checked) }
.padding(
horizontal = MaterialTheme.spacing.containerPadding,
vertical = MaterialTheme.spacing.space150
),
verticalAlignment = Alignment.CenterVertically
) {
Checkbox(checked = checked, onCheckedChange = onCheckedChange)
Spacer(modifier = Modifier.width(MaterialTheme.spacing.space125))
Text(text = text, style = MaterialTheme.typography.bodyMedium)
}
}
@ConformancePreviews
@Composable
private fun NostrSecretScreenPreview() {
press.mantra.compose.ui.theme.MantraTheme {
Surface(modifier = Modifier.fillMaxSize()) {
NostrSecretContent(
uiState = NostrSecretUIState.Revealed("nsec1" + "q".repeat(58)),
forgetting = NostrSecretUIState.Forgetting.Idle,
npub = "npub1" + "q".repeat(58),
canForget = true,
isBackupDone = false,
isDisclaimerRead = true,
showBackupNotice = true,
onReveal = {},
onHide = {},
onBackupDoneChange = {},
onDisclaimerReadChange = {},
onAskToForget = {},
onCancelForget = {},
onForget = {},
onNavigateBack = {},
)
}
}
}

View File

@@ -52,6 +52,7 @@ import press.mantra.compose.ui.composable.KeyRecoveryScreen
import press.mantra.compose.ui.composable.LandingScreen
import press.mantra.compose.ui.composable.LoadingScreen
import press.mantra.compose.ui.composable.NostrEventDetailScreen
import press.mantra.compose.ui.composable.NostrSecretScreen
import press.mantra.compose.ui.composable.RecoveryPhraseScreen
import press.mantra.compose.ui.composable.SearchMemberToAddToChatRoomScreen
import press.mantra.compose.ui.composable.SearchResultScreen
@@ -87,6 +88,7 @@ import press.mantra.compose.ui.composable.navigation.routes.KeyRecoveryRoute
import press.mantra.compose.ui.composable.navigation.routes.LandingRoute
import press.mantra.compose.ui.composable.navigation.routes.LoadingRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrEventDetailRoute
import press.mantra.compose.ui.composable.navigation.routes.NostrSecretRoute
import press.mantra.compose.ui.composable.navigation.routes.RecoveryPhraseRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchMemberToAddToChatRoomRoute
import press.mantra.compose.ui.composable.navigation.routes.SearchResultRoute
@@ -865,6 +867,27 @@ fun MantraNavHost(
}
)
}
composable<NostrSecretRoute> {
NostrSecretScreen(
phoenixGlobal = phoenixGlobal,
activeIdentityStateFlow = sovereignWalletViewModel.activeIdentity,
nostrRepository = databaseNostrRepository,
forgetNostrKey = { identity -> sovereignWalletViewModel.forgetNostrKey(identity) },
hideIdentityMetadata = { identity -> sovereignWalletViewModel.hideIdentityMetadata(identity) },
onNavigateBack = {
navController.popBackStack()
},
// The device no longer holds the key. Re-list so the selector drops it,
// then clear the active identity: the navigation observer sends a null
// identity to startup, which shows the selector -- or Landing, if this
// was the last one.
onForgotten = {
sovereignWalletViewModel.listIdentities {
sovereignWalletViewModel.resetToSelector()
}
},
)
}
composable<ChatRoomMessagingRoute> { backStackEntry ->
val route = backStackEntry.toRoute<ChatRoomMessagingRoute>()

View File

@@ -0,0 +1,7 @@
package press.mantra.compose.ui.composable.navigation.routes
import kotlinx.serialization.Serializable
/** The nostr secret key behind a bare-key identity: shown on request, and the way to forget it. */
@Serializable
data object NostrSecretRoute : Route()

View File

@@ -0,0 +1,213 @@
package press.mantra.compose.ui.view.model
import androidx.lifecycle.ViewModel
import androidx.lifecycle.ViewModelProvider
import androidx.lifecycle.viewModelScope
import androidx.lifecycle.viewmodel.initializer
import androidx.lifecycle.viewmodel.viewModelFactory
import co.touchlab.kermit.Logger
import fr.acinq.phoenix.PhoenixGlobal
import fr.acinq.phoenix.data.DecryptNostrKeysResult
import fr.acinq.phoenix.managers.NostrKeyManager
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.IO
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import press.mantra.compose.extensions.hexToNsecHrp
import press.mantra.compose.identity.Identity
import press.mantra.compose.identity.IdentityKind
import press.mantra.compose.identity.IdentityWriter
import press.mantra.compose.repository.NostrRepository
import press.mantra.compose.ui.view.state.NostrSecretUIState
/**
* Reads back the nostr secret key an identity was signed in with.
*
* The counterpart of [RecoveryPhraseViewModel] for the identity kind that has a key and
* no phrase. There is no wallet behind such an identity, so the key is the whole of the
* backup, and everything here is about getting it in front of the user once and
* recording that they say they stored it.
*
* The key is read from the file at reveal time, not off the active identity, so the
* screen's contract -- nothing secret held longer than it is shown -- is the same as the
* phrase screen's. The backup flags are the same per-identity preferences the phrase
* screen writes; they mean "this identity's secret is not backed up" for whichever
* secret it is.
*
* It also owns the inverse of an import: [forgetKey], which removes the key from the
* device and the account with it. Only for a bare key; a mnemonic identity's key is in
* the seed, and removing a seed is a wallet question this does not answer.
*/
class NostrSecretViewModel(
val phoenixGlobal: PhoenixGlobal,
val activeIdentityStateFlow: StateFlow<Identity?>,
private val nostrRepository: NostrRepository,
private val forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult,
private val hideIdentityMetadata: suspend (Identity) -> Unit,
) : ViewModel() {
private val logger = Logger.withTag(TAG)
private val _uiState = MutableStateFlow<NostrSecretUIState>(NostrSecretUIState.Hidden)
val uiState = _uiState.asStateFlow()
private val _forgetting = MutableStateFlow<NostrSecretUIState.Forgetting>(NostrSecretUIState.Forgetting.Idle)
val forgetting = _forgetting.asStateFlow()
/** Whether the active identity is one whose key this screen can show and forget. */
@OptIn(ExperimentalCoroutinesApi::class)
val isBareKeyIdentity: StateFlow<Boolean> = activeIdentityStateFlow
.flatMapLatest { flowOf(it?.kind == IdentityKind.NostrSecret) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
@OptIn(ExperimentalCoroutinesApi::class)
val isBackupDone: StateFlow<Boolean?> = activeIdentityStateFlow
.flatMapLatest { it?.internalPrefs?.isManualSeedBackupDone ?: flowOf(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val isDisclaimerRead: StateFlow<Boolean?> = activeIdentityStateFlow
.flatMapLatest { it?.internalPrefs?.isSeedLossDisclaimerRead ?: flowOf(null) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), null)
@OptIn(ExperimentalCoroutinesApi::class)
val showBackupNotice: StateFlow<Boolean> = activeIdentityStateFlow
.flatMapLatest { it?.internalPrefs?.showSeedBackupNotice ?: flowOf(false) }
.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5_000), false)
fun revealNostrSecret() {
if (_uiState.value is NostrSecretUIState.Revealing) return
val identity = activeIdentityStateFlow.value
if (identity == null) {
logger.e { "no active identity, cannot read a nostr key" }
_uiState.value = NostrSecretUIState.Error.NoActiveIdentity
return
}
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
logger.e("failed to read the nostr keys", throwable)
_uiState.value = NostrSecretUIState.Error.KeysUnreadable
}) {
_uiState.value = NostrSecretUIState.Revealing
// Keystore-backed, and it reads a file: it blocks, so it cannot run on the main thread.
val result = withContext(Dispatchers.IO) {
NostrKeyManager.loadAndDecrypt(phoenixGlobal)
}
_uiState.value = when (result) {
is DecryptNostrKeysResult.Success -> {
val privateKey = result.keys[identity.nostrPublicKey]
if (privateKey == null) {
logger.e { "key file holds no key for identity=${identity.id}" }
NostrSecretUIState.Error.NoKeyForThisIdentity
} else {
NostrSecretUIState.Revealed(privateKey.value.toHex().hexToNsecHrp())
}
}
is DecryptNostrKeysResult.Failure.FileNotFound -> {
logger.e { "no key file, so no key for identity=${identity.id}" }
NostrSecretUIState.Error.NoKeyForThisIdentity
}
is DecryptNostrKeysResult.Failure -> {
logger.e { "unable to read the nostr keys: $result" }
NostrSecretUIState.Error.KeysUnreadable
}
}
}
}
fun hideNostrSecret() {
_uiState.value = NostrSecretUIState.Hidden
}
fun setBackupDone(isDone: Boolean) {
val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
logger.e("could not save the backup flag", throwable)
}) {
internalPrefs.saveManualSeedBackupDone(isDone)
}
}
fun setDisclaimerRead(isRead: Boolean) {
val internalPrefs = activeIdentityStateFlow.value?.internalPrefs ?: return
viewModelScope.launch(CoroutineExceptionHandler { _, throwable ->
logger.e("could not save the disclaimer flag", throwable)
}) {
internalPrefs.saveSeedLossDisclaimerRead(isRead)
}
}
fun askToForgetKey() {
_forgetting.value = NostrSecretUIState.Forgetting.Confirming
}
fun cancelForgetKey() {
_forgetting.value = NostrSecretUIState.Forgetting.Idle
}
/**
* Key out of the file, preferences deleted, metadata hidden, account rows gone -- in
* that order, so a failure partway leaves the key on disk rather than an identity the
* selector lists but nothing can open. [onForgotten] runs on the main thread once the
* device no longer holds the key; the caller clears the active identity and re-lists.
*/
fun forgetKey(onForgotten: () -> Unit) {
val identity = activeIdentityStateFlow.value ?: return
if (_forgetting.value is NostrSecretUIState.Forgetting.InProgress) return
_forgetting.value = NostrSecretUIState.Forgetting.InProgress
_uiState.value = NostrSecretUIState.Hidden
viewModelScope.launch(Dispatchers.IO + CoroutineExceptionHandler { _, throwable ->
logger.e("could not forget the key", throwable)
_forgetting.value = NostrSecretUIState.Forgetting.Failed
}) {
when (forgetNostrKey(identity)) {
is IdentityWriter.ForgetNostrKeyResult.Forgotten -> {
hideIdentityMetadata(identity)
nostrRepository.forgetLocalAccount(identity.nostrPublicKey)
_forgetting.value = NostrSecretUIState.Forgetting.Idle
withContext(Dispatchers.Main) { onForgotten() }
}
is IdentityWriter.ForgetNostrKeyResult.NotABareKey,
is IdentityWriter.ForgetNostrKeyResult.CannotLoadKeys -> {
_forgetting.value = NostrSecretUIState.Forgetting.Failed
}
}
}
}
companion object {
private const val TAG = "NostrSecretViewModel"
fun factory(
phoenixGlobal: PhoenixGlobal,
activeIdentityStateFlow: StateFlow<Identity?>,
nostrRepository: NostrRepository,
forgetNostrKey: suspend (Identity) -> IdentityWriter.ForgetNostrKeyResult,
hideIdentityMetadata: suspend (Identity) -> Unit,
): ViewModelProvider.Factory = viewModelFactory {
initializer {
NostrSecretViewModel(
phoenixGlobal = phoenixGlobal,
activeIdentityStateFlow = activeIdentityStateFlow,
nostrRepository = nostrRepository,
forgetNostrKey = forgetNostrKey,
hideIdentityMetadata = hideIdentityMetadata,
)
}
}
}
}

View File

@@ -367,6 +367,30 @@ class SovereignWalletViewModel(
customElectrumServer = customElectrumServer.value,
)
/** The inverse of [writeNostrKey]. Only for a bare key; see [IdentityWriter.forgetNostrKey]. */
suspend fun forgetNostrKey(identity: Identity): IdentityWriter.ForgetNostrKeyResult =
IdentityWriter.forgetNostrKey(
log = log,
phoenixGlobal = phoenixGlobal,
id = identity.id,
nostrPublicKey = identity.nostrPublicKey,
)
/**
* Hides a forgotten identity's entry in the wallet metadata rather than deleting it:
* the metadata store has no delete, and `isHidden` is what the selector filters on.
*/
suspend fun hideIdentityMetadata(identity: Identity) {
val existing = getAvailableWalletsMeta(phoenixGlobal).first()[identity.id] ?: return
saveAvailableWalletMeta(
phoenixGlobal = phoenixGlobal,
walletId = identity.id,
name = existing.name,
avatar = existing.avatar,
isHidden = true,
)
}
companion object {
private const val TAG = "SovereignWalletViewModel"
fun factory(

View File

@@ -0,0 +1,35 @@
package press.mantra.compose.ui.view.state
/**
* What the nostr secret key screen is showing. The key is never held here longer than
* the user is looking at it: [Revealed] is the only state that carries it, and leaving
* the screen (or hiding it) drops back to [Hidden]. The shape of [RecoveryPhraseUIState],
* for the identity kind that has a key and no phrase.
*/
sealed interface NostrSecretUIState {
data object Hidden : NostrSecretUIState
/** Reading and decrypting the key file. */
data object Revealing : NostrSecretUIState
data class Revealed(val nsec: String) : NostrSecretUIState
sealed interface Error : NostrSecretUIState {
/** No identity is active, so there is nothing to look up. */
data object NoActiveIdentity : Error
/** The key file was read, but holds no key for the active identity -- it is a mnemonic identity, or the key was forgotten. */
data object NoKeyForThisIdentity : Error
/** The key file could not be read or decrypted. */
data object KeysUnreadable : Error
}
/** The forget flow's own states, alongside whatever the reveal is showing. */
sealed interface Forgetting {
data object Idle : Forgetting
data object Confirming : Forgetting
data object InProgress : Forgetting
data object Failed : Forgetting
}
}