feat(identity): a key the identity may not have

Phase 1 of docs/npub-sign-in.md. A type change and nothing a user can see.

Identity.nostrPrivateKey becomes nullable and nostrPublicKey becomes a
constructor field, because the kind this plan adds -- NostrPublic, a bare
public key -- has nothing to derive a pubkey from. A data class would let
the two disagree, so two checks in init refuse an identity whose kind and
key disagree about whether there is one, and one whose key does not derive
the public key it was given. Identity.signing derives the pubkey and refuses
the read-only kind; Identity.readOnly builds the other; nothing else
constructs one now. The id of a read-only identity is toWalletId() of the
x-only key -- the same id its nsec would have -- which is what will let a
read-only identity become a signing one and keep its preferences.

canSign is a property of the identity rather than nostrPrivateKey != null
at each site, so that a signer with no local key has one place to answer.

Every reader of the key already reached it through ?. on a nullable
identity, so the compiler forces nothing here; the sites that need a
decision are the plan's Phase 5, found by reading. The one site that did
change is KeyRecoveryScreen, whose two whens branched on NostrSecret with an
else that meant "mnemonic": both are exhaustive now, so the new kind is not
handed the phrase option by default.

Tests: NavigationIdentityRoutingJvmTest gains a read-only fixture with the
same id and pubkey as the secret one, the routing case for it (by its
account, like any other), and the three refusals plus the factory's own.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Pulled-From: curated/curated@15766596e4
This commit is contained in:
Kgothatso Ngako
2026-09-12 20:14:29 +02:00
parent 10df01f4b7
commit cf43973b36
7 changed files with 221 additions and 21 deletions

View File

@@ -7,6 +7,7 @@ import fr.acinq.bitcoin.XonlyPublicKey
import fr.acinq.bitcoin.byteVector
import fr.acinq.phoenix.PhoenixBusiness
import fr.acinq.phoenix.data.WalletId
import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.UserPrefs
@@ -16,7 +17,8 @@ import fr.acinq.phoenix.utils.preferences.UserPrefs
* The distinction is not cosmetic: the nostr key is a BIP32 leaf of the seed at
* `m/44'/1237'/0'/0/0`, and that derivation runs one way. Twelve words yield the
* key and a wallet; a bare key yields nothing further, so an identity made from
* one can never grow a node behind it. See docs/nsec-sign-in.md.
* one can never grow a node behind it; and a bare public key yields nothing at all,
* not even a signature. See docs/nsec-sign-in.md and docs/npub-sign-in.md.
*/
enum class IdentityKind {
/** Twelve words. The nostr key is derived from them, and so is a wallet. */
@@ -24,10 +26,14 @@ enum class IdentityKind {
/** A bare nostr secret. Nothing else can be derived from it. */
NostrSecret,
/** A bare nostr public key. Nothing can be signed, opened or derived from it. */
NostrPublic,
}
/**
* The signing identity the app is running as.
* The signing identity the app is running as -- or, for [IdentityKind.NostrPublic],
* the identity it is looking at.
*
* This is what every consumer of "the active user" reads, in place of reaching
* through the wallet -- `business.walletManager.keyManager.value.nostrPrivateKey()`
@@ -37,32 +43,96 @@ enum class IdentityKind {
* Marmot bundle password is `hash160` of it. None of them can tell where it came
* from, and after this type none of them need to.
*
* [nostrPublicKey] is a field rather than a derivation because a read-only identity
* has nothing to derive it from. The two checks in `init` keep the field and the key
* from disagreeing, which a data class would otherwise happily allow; [signing] and
* [readOnly] are the two ways to get one built without tripping them.
*
* @param id keys the per-identity preference files and the wallet metadata. For a
* [IdentityKind.Mnemonic] identity it is the hash160 of the node id, as it always
* was; for a [IdentityKind.NostrSecret] identity it is the hash160 of the x-only
* public key ([toWalletId]). Same shape, so nothing downstream can tell the kinds
* apart by the id -- the kind is here, where it can be asked.
* was; for the two bare kinds it is the hash160 of the x-only public key
* ([toWalletId]) -- the *same* id whether the device holds the secret or only the
* public key, which is what lets a read-only identity become a signing one and keep
* its preferences. Same shape as a wallet's, so nothing downstream can tell the
* kinds apart by the id -- the kind is here, where it can be asked.
* @param nostrPrivateKey null only for [IdentityKind.NostrPublic].
* @param business the running node. Non-null only for [IdentityKind.Mnemonic].
*/
data class Identity(
val id: WalletId,
val kind: IdentityKind,
val nostrPrivateKey: PrivateKey,
val userPrefs: UserPrefs,
val internalPrefs: InternalPrefs,
val business: PhoenixBusiness?,
) {
/**
* X-only, hex -- the form every nostr call site wants. Computed once.
* X-only, hex -- the form every nostr call site wants.
*
* Not `publicKey().toHex()`: that is the 33-byte compressed encoding, sixty-six
* hex characters, and it is what the library's own `nostrPublicKey()` returns.
* Nostr keys are the 32-byte x coordinate.
*/
val nostrPublicKey: HexKey = nostrPrivateKey.publicKey().xOnly().value.toHex()
val nostrPublicKey: HexKey,
val nostrPrivateKey: PrivateKey?,
val userPrefs: UserPrefs,
val internalPrefs: InternalPrefs,
val business: PhoenixBusiness?,
) {
/**
* What the notary, the pumps and every write entrance ask.
*
* A property of the identity rather than `nostrPrivateKey != null` repeated at each
* site, so that a signer that holds no local key -- a remote one, some day -- has
* one place to answer *true*.
*/
val canSign: Boolean get() = nostrPrivateKey != null
init {
require((nostrPrivateKey == null) == (kind == IdentityKind.NostrPublic)) {
"identity kind $kind and its key disagree about whether there is one"
}
require(nostrPrivateKey == null || nostrPrivateKey.nostrPublicKeyHex() == nostrPublicKey) {
"identity key does not derive the public key it was given"
}
}
/** The key must never reach a log. `data class` would print it. */
override fun toString(): String = "Identity(id=$id, kind=$kind, key=<redacted>)"
companion object {
/** An identity that holds its secret: the public key is derived, never supplied. */
fun signing(
id: WalletId,
kind: IdentityKind,
nostrPrivateKey: PrivateKey,
userPrefs: UserPrefs,
internalPrefs: InternalPrefs,
business: PhoenixBusiness?,
): Identity {
require(kind != IdentityKind.NostrPublic) { "a read-only identity has no secret to sign with" }
return Identity(
id = id,
kind = kind,
nostrPublicKey = nostrPrivateKey.nostrPublicKeyHex(),
nostrPrivateKey = nostrPrivateKey,
userPrefs = userPrefs,
internalPrefs = internalPrefs,
business = business,
)
}
/** An identity the device holds only the public key of. No secret, no node. */
fun readOnly(
id: WalletId,
nostrPublicKey: HexKey,
userPrefs: UserPrefs,
internalPrefs: InternalPrefs,
): Identity = Identity(
id = id,
kind = IdentityKind.NostrPublic,
nostrPublicKey = nostrPublicKey,
nostrPrivateKey = null,
userPrefs = userPrefs,
internalPrefs = internalPrefs,
business = null,
)
}
}
/**

View File

@@ -129,10 +129,17 @@ fun KeyRecoveryScreen(
horizontalAlignment = Alignment.CenterHorizontally
) {
Text(
// A bare-key identity has no coins to speak of.
// A bare-key identity has no coins to speak of. Exhaustive rather than
// `else`: the `else` here used to mean "mnemonic", and a kind added later
// would have fallen into it and been promised coins. A read-only identity
// is never offered this screen (docs/npub-sign-in.md, Phase 5); it reads
// as the bare-key kind so that, if it ever is, the sentence is at least
// not about a wallet.
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)
IdentityKind.NostrSecret,
IdentityKind.NostrPublic -> stringResource(Res.string.these_are_your_keys_keep_them_safe_no_wallet)
IdentityKind.Mnemonic,
null -> stringResource(Res.string.these_are_your_keys_keep_them_safe_so_they)
},
style = MaterialTheme.typography.bodyMedium,
textAlign = TextAlign.Center
@@ -174,7 +181,14 @@ fun KeyRecoveryScreen(
}
)
else -> KeyRecoveryOption(
// Nothing to recover: the device holds no secret for this identity. The
// row that leads here is hidden for the kind, so this arm is not reached;
// it exists so that the `when` says so rather than handing a read-only
// identity the phrase option through an `else`.
IdentityKind.NostrPublic -> Unit
IdentityKind.Mnemonic,
null -> KeyRecoveryOption(
icon = Icons.Default.Spellcheck,
title = stringResource(Res.string.recovery_phrase),
description = stringResource(Res.string.write_down_and_secure_the_12_word_phrase),

View File

@@ -189,7 +189,7 @@ fun SovereignWalletStartupScreen(
// recomposes into the `activeIdentity != null` branch
// above, which is what reports the startup.
sovereignWalletViewModel.setActiveIdentity(
Identity(
Identity.signing(
id = identity.id,
kind = IdentityKind.NostrSecret,
nostrPrivateKey = identity.privateKey,

View File

@@ -158,7 +158,7 @@ class SovereignWalletViewModel(
val dataStoreManager = DataStoreManager(business)
setActiveIdentity(
Identity(
Identity.signing(
id = walletId,
kind = IdentityKind.Mnemonic,
nostrPrivateKey = keyManager.nostrPrivateKey(),

View File

@@ -126,7 +126,7 @@ class NsecRestoreRoundTripJvmTest {
// 3. What the startup screen's NostrSecret branch does: an identity, no node.
val dataStoreManager = DataStoreManager(phoenixGlobal.ctx, chain = NodeParamsManager.chain)
val identity = Identity(
val identity = Identity.signing(
id = stored.id,
kind = IdentityKind.NostrSecret,
nostrPrivateKey = stored.privateKey,

View File

@@ -6,6 +6,7 @@ import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip01Core.crypto.KeyPair
import fr.acinq.bitcoin.ByteVector32
import fr.acinq.bitcoin.PrivateKey
import fr.acinq.phoenix.managers.nostrPublicKeyHex
import fr.acinq.phoenix.utils.preferences.InternalPrefs
import fr.acinq.phoenix.utils.preferences.UserPrefs
import kotlinx.coroutines.CoroutineScope
@@ -31,6 +32,10 @@ import press.mantra.compose.ui.composable.navigation.routes.SovereignWalletStart
import press.mantra.compose.ui.view.state.NavigationUIState
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertFailsWith
import kotlin.test.assertFalse
import kotlin.test.assertNull
import kotlin.test.assertTrue
import kotlin.time.Instant
/**
@@ -65,7 +70,7 @@ class NavigationIdentityRoutingJvmTest {
temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath()
}
private fun nostrSecretIdentity() = Identity(
private fun nostrSecretIdentity() = Identity.signing(
id = privateKey.publicKey().xOnly().toWalletId(),
kind = IdentityKind.NostrSecret,
nostrPrivateKey = privateKey,
@@ -74,6 +79,14 @@ class NavigationIdentityRoutingJvmTest {
business = null,
)
/** The same key, held as a public key only: same id, no secret, no node. */
private fun readOnlyIdentity() = Identity.readOnly(
id = privateKey.publicKey().xOnly().toWalletId(),
nostrPublicKey = privateKey.nostrPublicKeyHex(),
userPrefs = UserPrefs(prefsStore("user")),
internalPrefs = InternalPrefs(prefsStore("internal")),
)
/** Everything `observeProfile` reads, and nothing else; every other member throws. */
private class Device(
private val accounts: List<LocalAccount>
@@ -176,4 +189,107 @@ class NavigationIdentityRoutingJvmTest {
viewModel.navigationUIState.value,
)
}
/**
* A read-only identity is the same identity minus the secret: same public key, same
* id, and the machine routes it by its account exactly as it routes the nsec.
*/
@Test
fun `a read-only identity has the id and public key its secret would, and cannot sign`() {
val readOnly = readOnlyIdentity()
val signing = nostrSecretIdentity()
assertEquals(signing.nostrPublicKey, readOnly.nostrPublicKey)
assertEquals(signing.id, readOnly.id)
assertNull(readOnly.nostrPrivateKey)
assertNull(readOnly.business)
assertFalse(readOnly.canSign)
assertTrue(signing.canSign)
assertEquals(IdentityKind.NostrPublic, readOnly.kind)
}
@Test
fun `a read-only identity is routed by its account, like any other`() {
val identity = readOnlyIdentity()
val scope = CoroutineScope(Job())
val viewModel = NavigationViewModel(
activeIdentityStateFlow = MutableStateFlow<Identity?>(identity),
initialNavigationUIState = stranded,
nostrRepository = Device(listOf(syncedAccount(identity.nostrPublicKey))),
scope = scope,
)
try {
val destination = runBlocking {
withTimeout(15_000) { viewModel.navigationUIState.first { it != stranded } }
}
assertEquals(
NavigationUIState.ProfileLoaded(publicKey = identity.nostrPublicKey),
destination,
"a read-only identity was not routed by its account",
)
} finally {
scope.cancel()
}
}
/**
* The two checks that make [Identity.nostrPublicKey] safe to be a field. Without
* them a data class would accept a key filed under someone else's public key, or a
* "read-only" identity that could in fact sign.
*/
@Test
fun `an identity whose kind and key disagree is refused, and so is one whose key does not derive its public key`() {
val prefs = UserPrefs(prefsStore("user"))
val internal = InternalPrefs(prefsStore("internal"))
// A secret filed under a public key it does not derive.
assertFailsWith<IllegalArgumentException> {
Identity(
id = privateKey.publicKey().xOnly().toWalletId(),
kind = IdentityKind.NostrSecret,
nostrPublicKey = "a".repeat(64),
nostrPrivateKey = privateKey,
userPrefs = prefs,
internalPrefs = internal,
business = null,
)
}
// A read-only kind that nonetheless carries a key.
assertFailsWith<IllegalArgumentException> {
Identity(
id = privateKey.publicKey().xOnly().toWalletId(),
kind = IdentityKind.NostrPublic,
nostrPublicKey = privateKey.nostrPublicKeyHex(),
nostrPrivateKey = privateKey,
userPrefs = prefs,
internalPrefs = internal,
business = null,
)
}
// A signing kind with no key to sign with.
assertFailsWith<IllegalArgumentException> {
Identity(
id = privateKey.publicKey().xOnly().toWalletId(),
kind = IdentityKind.NostrSecret,
nostrPublicKey = privateKey.nostrPublicKeyHex(),
nostrPrivateKey = null,
userPrefs = prefs,
internalPrefs = internal,
business = null,
)
}
// And the factory refuses to build a "signing" read-only identity at all.
assertFailsWith<IllegalArgumentException> {
Identity.signing(
id = privateKey.publicKey().xOnly().toWalletId(),
kind = IdentityKind.NostrPublic,
nostrPrivateKey = privateKey,
userPrefs = prefs,
internalPrefs = internal,
business = null,
)
}
}
}

View File

@@ -68,7 +68,7 @@ class NostrSecretViewModelJvmTest {
temporaryFolder.newFolder().resolve("$name.preferences_pb").path.toPath()
}
private fun identity() = Identity(
private fun identity() = Identity.signing(
id = privateKey.publicKey().xOnly().toWalletId(),
kind = IdentityKind.NostrSecret,
nostrPrivateKey = privateKey,