feat(cashu): wire NUT-13 deterministic secrets into the live mint flow

The earlier commit landed the math primitives + spec-vector tests. This
commit threads them into the actual blind-message construction so every
mint / swap / melt the wallet performs now uses NUT-13-derived secrets
instead of pure randomness. Side effect: kind:7375 loss is no longer
permanent funds loss — the wallet can re-derive past secrets from the
seed and recover via NUT-09 /v1/restore (driver loop still to come).

What's here:

- `SecretFactory` strategy in quartz mintApi/. Two impls:
    RandomSecretFactory — pure random; the new default for tests and
      legacy callers that don't carry a seed.
    DeterministicSecretFactory — NUT-13 derivation via a (seedProvider,
      reserveCounter) pair. Seed is lazy (the wallet decrypts kind:17375
      asynchronously); if the cache is empty, it falls back to random,
      preserving pre-NUT-13 behaviour during the warm-up window.
- `CashuMintOperations` constructor now takes a SecretFactory
  (default: random). `secretOutputFor` delegates to it. The on-wire
  shape is identical either way — the mint can't tell which scheme
  we're using.
- `CashuWalletOps` constructor takes a SecretFactory + a suspend
  seedWarmer callback. Every blinding op (mintProofs / meltToLightning
  / sendNutzap / redeemNutzap) calls seedWarmer() before constructing
  outputs so the seed cache is warm by the time the synchronous
  SecretFactory queries it.
- `CashuDeterministic.deriveWalletSeed(p2pkPrivkey)` — derives the
  64-byte NUT-13 master seed from the wallet's existing P2PK key via
  `HMAC-SHA512("Cashu-Wallet-Seed-v1", priv)`. No new field on
  kind:17375 needed; existing wallets become recoverable on first use.
  Distinct key-derivation domain so leaking a Cashu secret doesn't
  expose the P2PK key.
- `CashuWalletState.cachedSeed` (@Volatile) + `ensureSeed()` (suspend)
  — derives once on first call (paying the signer round-trip for the
  P2PK key), caches for the wallet's lifetime. Pure function of the
  P2PK key, so the cache never invalidates.
- `AccountSettings.cashuKeysetCounters` (`MutableMap<keysetId, Long>`)
  + `reserveCashuCounters(keysetId, count)` — atomic, persistent
  read-modify-write that hands out a strictly-monotonic counter range.
  Two contracts the spec demands: never reuse a (seed, keysetId,
  counter) tuple, and persist the increment BEFORE the secret is used.
  Both satisfied — the saveAccountSettings call happens inside the
  @Synchronized block before reserveCashuCounters returns.
- `peekCashuCounter(keysetId)` — read-only inspector for the upcoming
  NUT-09 restore driver loop (needs to know how high to scan).

Approach (a) — derive seed from the existing P2PK key — was chosen
over (b) — add a new BIP-39 mnemonic field to kind:17375 — because:
  - Zero migration: every existing wallet becomes recoverable on next
    op, no save-and-republish required.
  - The P2PK key is already the recovery-critical secret in kind:17375.
    Anyone with the kind:17375 can derive the seed and reconstruct
    everything; same threat model as today.
  - Cross-wallet recovery via mnemonic is a separate UX feature we can
    layer later by also storing/importing a mnemonic when the user
    wants that contract.

Backwards compatibility: SecretFactory defaults to RandomSecretFactory
on CashuMintOperations and CashuWalletOps, so existing direct
constructions (mint-operations tests, ad-hoc helper code) keep their
previous behaviour. Production goes through CashuWalletState, which
injects the deterministic factory.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:44 +00:00
parent d971ff4da7
commit d7e447428c
6 changed files with 271 additions and 7 deletions
@@ -206,6 +206,19 @@ class AccountSettings(
var backupTrustProviderList: TrustProviderListEvent? = null,
var backupCashuWallet: CashuWalletEvent? = null,
var backupNutzapInfo: NutzapInfoEvent? = null,
/**
* NUT-13 deterministic-secret counter map, keyed by keyset id. The
* wallet derives every blind message from `(seed, keysetId, counter)`,
* incrementing the counter every time it consumes one; reusing a
* counter would expose the secret. Persisted here so the counter
* survives app restart even though the wallet's seed is also stored
* in kind:17375 (which would otherwise be the only persistence).
*
* Empty map = no NUT-13 usage yet (e.g. wallet created before this
* feature shipped). Per-keyset; the same counter under different
* keysets is fine because the derivation includes the keyset id.
*/
var cashuKeysetCounters: MutableMap<String, Long> = mutableMapOf(),
val lastReadPerRoute: MutableStateFlow<Map<String, MutableStateFlow<Long>>> = MutableStateFlow(mapOf()),
val hasDonatedInVersion: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
val dismissedPollNoteIds: MutableStateFlow<Set<String>> = MutableStateFlow(setOf()),
@@ -794,6 +807,30 @@ class AccountSettings(
}
}
/**
* Reserve [count] consecutive NUT-13 counters for [keysetId], returning
* the first one. Caller derives `(secret, r)` from `(seed, keysetId, i)`
* for `i in [returned .. returned+count-1]`. Persisted immediately so
* a crash mid-mint doesn't reuse the same counter on next launch.
*
* Synchronized to make the read-modify-write atomic — two coroutines
* minting concurrently must each get their own counter range.
*/
@Synchronized
fun reserveCashuCounters(
keysetId: String,
count: Int,
): Long {
require(count > 0) { "Counter reservation must be positive" }
val current = cashuKeysetCounters[keysetId] ?: 0L
cashuKeysetCounters[keysetId] = current + count.toLong()
saveAccountSettings()
return current
}
/** Inspect the next counter for [keysetId] without consuming any. */
fun peekCashuCounter(keysetId: String): Long = cashuKeysetCounters[keysetId] ?: 0L
fun updateNIPA3PaymentTargets(newNIPA3PaymentTargets: PaymentTargetsEvent?) {
if (newNIPA3PaymentTargets == null || newNIPA3PaymentTargets.tags.isEmpty()) return
@@ -41,6 +41,8 @@ import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpClient
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintHttpException
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintProtocolException
import com.vitorpamplona.quartz.nip60Cashu.mintApi.MintQuoteBolt11ResponseDto
import com.vitorpamplona.quartz.nip60Cashu.mintApi.RandomSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.mintApi.SecretFactory
import com.vitorpamplona.quartz.nip60Cashu.p2pk.P2PK
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.token.CashuProof
@@ -77,12 +79,27 @@ class CashuWalletOps(
private val signer: NostrSigner,
private val publish: suspend (Event) -> Unit,
private val okHttpClient: (String) -> OkHttpClient,
/**
* NUT-13 secret strategy. Defaults to random for backwards
* compatibility with tests that don't carry a seed. The wallet state
* supplies a [DeterministicSecretFactory] in production so kind:7375
* loss is recoverable via NUT-09 /v1/restore.
*/
private val secretFactory: SecretFactory = RandomSecretFactory,
/**
* Suspend callback that ensures the NUT-13 seed is materialised in
* the caller's cache before any blinding op runs. The factory above
* reads that cache synchronously — without warming first, a fresh
* wallet falls back to random secrets for its very first mint.
* Default is a no-op for tests / random-only callers.
*/
private val seedWarmer: suspend () -> Unit = {},
) {
private val opsCache = ConcurrentHashMap<String, CashuMintOperations>()
private fun ops(mintUrl: String): CashuMintOperations =
opsCache.getOrPut(mintUrl.trimEnd('/')) {
CashuMintOperations(MintHttpClient(mintUrl, okHttpClient))
CashuMintOperations(MintHttpClient(mintUrl, okHttpClient), secretFactory)
}
/**
@@ -187,6 +204,7 @@ class CashuWalletOps(
quoteEvent: CashuMintQuoteEvent,
amountSats: Long,
): MintCompleted {
seedWarmer()
val quoteId = quoteEvent.quoteId(signer)
val minted = ops(mintUrl).mintProofs(quoteId, amountSats)
@@ -246,6 +264,7 @@ class CashuWalletOps(
quote: MeltQuoteBolt11ResponseDto,
available: List<TokenEntry>,
): MeltCompleted {
seedWarmer()
if (available.isEmpty()) throw IllegalStateException("No proofs available to spend")
val ops = ops(mintUrl)
@@ -459,6 +478,7 @@ class CashuWalletOps(
available: List<TokenEntry>,
): NutzapSent {
if (amountSats <= 0) throw IllegalArgumentException("Amount must be positive")
seedWarmer()
val (selected, totalSelected) = selectProofsCovering(available, amountSats)
if (totalSelected < amountSats) throw IllegalStateException("Insufficient balance for $mintUrl")
@@ -556,6 +576,7 @@ class CashuWalletOps(
walletPrivkeyHex: String,
walletP2pkPubkeyHex: String,
): RedeemCompleted {
seedWarmer()
val mintUrl =
nutzap.mintUrl()
?: throw IllegalArgumentException("Nutzap has no mint tag")
@@ -33,7 +33,9 @@ import com.vitorpamplona.quartz.nip01Core.relay.normalizer.NormalizedRelayUrl
import com.vitorpamplona.quartz.nip01Core.signers.NostrSigner
import com.vitorpamplona.quartz.nip09Deletions.DeletionEvent
import com.vitorpamplona.quartz.nip60Cashu.history.CashuSpendingHistoryEvent
import com.vitorpamplona.quartz.nip60Cashu.mintApi.DeterministicSecretFactory
import com.vitorpamplona.quartz.nip60Cashu.quote.CashuMintQuoteEvent
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
import com.vitorpamplona.quartz.nip60Cashu.token.CashuTokenEvent
import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
import com.vitorpamplona.quartz.nip60Cashu.wallet.CashuWalletEvent
@@ -97,6 +99,20 @@ class CashuWalletState(
signer = signer,
publish = ::publishEvent,
okHttpClient = okHttpClient,
// NUT-13 wiring: the factory closure reads the cached seed at
// mint-op time. cachedSeed is populated by ensureSeed() —
// CashuWalletOps' seedWarmer below calls it before any blind
// op so the cache is warm. When the cache is empty (no wallet
// decrypted yet) the factory falls back to random, matching
// pre-NUT-13 behaviour. Counter allocation is synchronous +
// persistent via AccountSettings; an atomic read-modify-write
// makes concurrent mints safe.
secretFactory =
DeterministicSecretFactory(
seedProvider = ::cachedSeedOrNull,
reserveCounter = { keysetId -> settings.reserveCashuCounters(keysetId, count = 1) },
),
seedWarmer = { ensureSeed() },
)
// ============================================================
@@ -200,6 +216,44 @@ class CashuWalletState(
runCatching { evt.privkey(signer) }.getOrNull()
}
/**
* Cached NUT-13 master seed derived from the wallet's P2PK private
* key. Volatile + double-checked lazy init — the seed never changes
* for a given wallet (it's a pure function of the P2PK key, which is
* a constant in kind:17375), so once derived we hold it for the
* wallet's lifetime. Null until the first time something asks.
*
* Derivation: HMAC-SHA512("Cashu-Wallet-Seed-v1", p2pk_priv) — yields
* a 64-byte seed shaped like BIP-39's PBKDF2 output. We use HMAC
* rather than the raw private key so any downstream NIP-44-style
* leakage of secrets derived from `seed` doesn't compromise the
* P2PK key itself.
*/
@Volatile private var cachedSeed: ByteArray? = null
/**
* Fetch (and cache on first call) the NUT-13 master seed. Returns
* null when the wallet hasn't decrypted its kind:17375 yet — the
* secret factory falls back to random in that window. Once the seed
* is cached, every subsequent mint operation gets deterministic
* secrets and the counter advances monotonically.
*/
private suspend fun ensureSeed(): ByteArray? {
cachedSeed?.let { return it }
val priv = walletPrivkeyHex() ?: return null
val seed = CashuDeterministic.deriveWalletSeed(priv.hexToByteArray())
cachedSeed = seed
return seed
}
/**
* Synchronous seed accessor for the [SecretFactory] thunk. Returns
* whatever's already in [cachedSeed] — does NOT trigger derivation
* (which is suspend). Callers must invoke [ensureSeed] before the
* mint op so the cache is warm by the time the factory queries it.
*/
private fun cachedSeedOrNull(): ByteArray? = cachedSeed
// ============================================================
// Lifecycle
// ============================================================
@@ -0,0 +1,119 @@
/*
* Copyright (c) 2025 Vitor Pamplona
*
* Permission is hereby granted, free of charge, to any person obtaining a copy of
* this software and associated documentation files (the "Software"), to deal in
* the Software without restriction, including without limitation the rights to use,
* copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the
* Software, and to permit persons to whom the Software is furnished to do so,
* subject to the following conditions:
*
* The above copyright notice and this permission notice shall be included in all
* copies or substantial portions of the Software.
*
* THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
* IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
* FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
* COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN
* AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION
* WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
*/
package com.vitorpamplona.quartz.nip60Cashu.mintApi
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke
import com.vitorpamplona.quartz.nip60Cashu.seed.CashuDeterministic
/**
* A pair of secret + blinding factor for one BDHKE blind message.
* - [secretHex] is the lowercase hex of the 32-byte secret. NUT-00
* specifies the secret is the UTF-8 encoding of this hex string, so
* consumers always call `secretHex.encodeToByteArray()` before
* passing to `Bdhke.blind` / `hashToCurve`.
* - [blindingFactor] is 32 raw bytes, a valid secp256k1 scalar.
*/
data class DerivedSecret(
val secretHex: String,
val blindingFactor: ByteArray,
) {
override fun equals(other: Any?): Boolean =
other is DerivedSecret &&
other.secretHex == secretHex &&
other.blindingFactor.contentEquals(blindingFactor)
override fun hashCode(): Int = 31 * secretHex.hashCode() + blindingFactor.contentHashCode()
}
/**
* Strategy for producing the (secret, r) pairs that go into BDHKE blind
* messages. Two impls today:
* - [RandomSecretFactory] — fresh randomness for each call. Default.
* Forwards-compatible with NUT-09 restore in the sense that the mint
* will still hand back the proofs if we have the secrets — but we
* won't have them after a wallet-event loss.
* - [DeterministicSecretFactory] — NUT-13 derivation from a seed plus
* a per-keyset counter. The wallet can re-derive past secrets after a
* catastrophic kind:7375 loss and recover via NUT-09 /v1/restore.
*
* The factory is keyset-aware because NUT-13 derivation depends on the
* keyset id (different keysets yield different secrets from the same
* seed+counter), and the counter is per-keyset.
*/
interface SecretFactory {
/** Mint one (secret, r) pair for use on the specified keyset. */
fun nextSecret(keysetId: String): DerivedSecret
}
/**
* Random secret + random blinding factor. No deterministic recovery —
* losing the kind:7375 token events is permanent funds loss. Suitable
* as a fallback when no seed is available (e.g. a wallet that
* pre-dates the NUT-13 wiring).
*/
object RandomSecretFactory : SecretFactory {
override fun nextSecret(keysetId: String): DerivedSecret {
val secret = Bdhke.randomSecret()
val r = Bdhke.randomScalar()
return DerivedSecret(secret.toHexKey(), r)
}
}
/**
* NUT-13 deterministic secret factory.
*
* [seedProvider] is a thunk that returns the wallet's seed when
* available, null when the wallet hasn't decrypted its kind:17375 yet.
* Lazy-resolving instead of taking the seed up-front lets the factory
* be constructed at wallet-state init time (before any signer round-
* trip) — when the seed isn't ready, the factory transparently falls
* back to [fallback], preserving the no-NUT-13 invariant.
*
* [reserveCounter] is a thunk that atomically increments and returns the
* previous value for a given keyset id — that's the wallet's persistent
* counter state, NOT a fresh random index.
*
* Two reserveCounter contracts the caller MUST honour:
* 1. Returned counters are STRICTLY MONOTONIC per keyset id. Reusing one
* under the same (seed, keysetId) reuses the same (secret, r) pair —
* reveals the seed-derivation relationship and could let an observer
* correlate proofs across mints.
* 2. The increment is PERSISTED before the secret is actually used in a
* mint request. Otherwise a crash mid-mint reuses on next launch.
*
* `AccountSettings.reserveCashuCounters` satisfies both.
*/
class DeterministicSecretFactory(
private val seedProvider: () -> ByteArray?,
private val reserveCounter: (keysetId: String) -> Long,
private val fallback: SecretFactory = RandomSecretFactory,
) : SecretFactory {
override fun nextSecret(keysetId: String): DerivedSecret {
val seed = seedProvider() ?: return fallback.nextSecret(keysetId)
val counter = reserveCounter(keysetId)
// CashuDeterministic.secretBytes returns the raw 32 bytes; the
// hex form is what BDHKE/proof storage actually use.
val secretHex = CashuDeterministic.secretBytes(seed, keysetId, counter).toHexKey()
val r = CashuDeterministic.blindingFactor(seed, keysetId, counter)
return DerivedSecret(secretHex, r)
}
}
@@ -73,6 +73,31 @@ object CashuDeterministic {
private const val CASHU_PURPOSE: Long = 129372L
private const val CASHU_COIN_TYPE: Long = 0L
private val V01_HMAC_PREFIX = "Cashu_KDF_HMAC_SHA256".encodeToByteArray()
private val WALLET_SEED_KEY = "Cashu-Wallet-Seed-v1".encodeToByteArray()
/**
* Derive a 64-byte NUT-13 master seed from a wallet's P2PK private
* key. Useful for wallets that don't (yet) carry a BIP-39 mnemonic
* but do persist a long-lived private key — the kind:17375 wallet
* event is the canonical example.
*
* Derivation: `HMAC-SHA512("Cashu-Wallet-Seed-v1", p2pk_priv)`.
* - Output shape (64 bytes) matches BIP-39's PBKDF2-HMAC-SHA512
* output so existing NUT-13 derivation paths accept it unchanged.
* - Using HMAC ensures any leakage of secrets derived from the seed
* doesn't reveal the P2PK key itself — distinct key-derivation
* domains.
* - The "v1" tag isolates this derivation from any future scheme
* we want to roll out without breaking existing wallets.
*
* Idempotent and pure; safe to recompute on every wallet load.
*/
fun deriveWalletSeed(p2pkPrivkey: ByteArray): ByteArray {
require(p2pkPrivkey.size == 32) { "P2PK private key must be 32 bytes" }
val mac = MacInstance("HmacSHA512", WALLET_SEED_KEY)
mac.update(p2pkPrivkey)
return mac.doFinal()
}
/**
* 32 raw bytes of the derived secret. The Cashu protocol uses these
@@ -38,6 +38,13 @@ import com.vitorpamplona.quartz.nip60Cashu.token.TokenContent
*/
class CashuMintOperations(
private val client: MintHttpClient,
/**
* NUT-13 strategy for secret + blinding-factor generation. Defaults to
* pure-random; the wallet supplies a [DeterministicSecretFactory] when
* it has a seed available so future kind:7375 losses can be recovered
* via NUT-09 /v1/restore.
*/
private val secretFactory: SecretFactory = RandomSecretFactory,
) {
/**
* Step 1 of mint-from-LN: ask the mint for a bolt11 invoice for `amount`
@@ -307,12 +314,13 @@ class CashuMintOperations(
amount: Long,
keyset: KeysetDto,
): BlindOutput {
val secret = Bdhke.randomSecret()
val r = Bdhke.randomScalar()
// NUT-00 spec: secret is a hex string of 32 random bytes.
val secretHex = secret.toHexKey()
val bTick = Bdhke.blind(secretHex.encodeToByteArray(), r)
return BlindOutput(amount, keyset.id, r, secretHex, bTick)
// NUT-00: secret is a UTF-8 hex string of 32 secret bytes.
// [secretFactory] decides whether those bytes are pure-random or
// NUT-13-derived from a wallet seed; either way the on-wire shape
// is identical so the mint can't tell which scheme we're using.
val derived = secretFactory.nextSecret(keyset.id)
val bTick = Bdhke.blind(derived.secretHex.encodeToByteArray(), derived.blindingFactor)
return BlindOutput(amount, keyset.id, derived.blindingFactor, derived.secretHex, bTick)
}
/**