feat(cashu): NUT-13 deterministic secrets + NUT-09 restore endpoint

Foundation for "lose your kind:7375 token events → still recover the
funds from a seed" — currently a permanent funds loss because secrets
are purely random and the mint won't return proofs the wallet can't
prove ownership of. NUT-13 fixes that by deriving every (secret, r)
pair from a seed + per-keyset counter, and NUT-09 gives the wallet a
way to ask the mint "which of these blinded messages have you signed?"
so a fresh wallet can reconstruct historic proofs.

What's here:

- `CashuDeterministic` (quartz commonMain) — full NUT-13 derivation
  for both v00 and v01 keyset id formats. The two cases need different
  algorithms:
    v00 (8-byte id): BIP-32 hardened derivation at
      `m/129372'/0'/{keyset_id_int}'/{counter}'/{0|1}`
      keyset_id_int = int.from_bytes(keyset_id) % (2^31 - 1)
    v01 (33-byte id): HMAC-SHA256 directly off the seed —
      base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
      secret = HMAC(seed, base || 0x00)
      r      = HMAC(seed, base || 0x01)
  The v01 swap is necessary because a 33-byte id can't faithfully
  encode into a 31-bit BIP-32 child index without lossy collapse.
  First byte of the id selects the branch (`00` → v00, `01` → v01).

- `RestoreRequestDto` / `RestoreResponseDto` + `/v1/restore` on the
  mint HTTP client (NUT-09). Wallet sends a batch of blinded
  messages; mint echoes back the subset it has previously signed +
  the issued signatures. Caller is responsible for the scan loop
  (try counters [0..N], stop after M consecutive empty batches).

Tests against the verbatim NUT-13 spec vectors (mnemonic = "half
depart obvious quality work element tank gorilla view sugar picture
humble", both v00 and v01 keysets, counters 0–4):
- v00: 5 counters × {secret, blinding} = 10 vector checks
- v01: 2 counters × {secret, blinding} = 4 vector checks (samples)
- 4 sanity properties: leaf 0 ≠ leaf 1, counter advances change
  output, different keysets produce different secrets, secretAsAscii
  is exactly 64 lowercase hex chars
- 3 keysetIdToInt edge cases: zero, small, fits-in-31-bits

Initial implementation had v00 working but v01 producing wrong output
— spent some time confirming the spec actually swaps algorithm based
on version byte (cashu-ts and nutshell both do; spec text doesn't
make this obvious). The cashubtc/nutshell `_derive_secret_hmac_sha256`
is the authoritative reference for the v01 path.

Not yet wired into CashuMintOperations — that's the next commit
(per-keyset counter persistence, deterministic blinding on mint/swap,
the actual restore() driver loop, UI for "recover from seed"). This
commit lands the math primitives + endpoint plumbing so the
follow-up is integration only, no further protocol decisions.

https://claude.ai/code/session_01MdWddiar819f8XYt5N8BjP
This commit is contained in:
Claude
2026-05-27 15:17:44 +00:00
parent 70e04523ea
commit c2f229b1ee
4 changed files with 434 additions and 0 deletions
@@ -106,6 +106,28 @@ data class BlindedMessageDto(
@SerialName("B_") val bTick: String,
)
/**
* NUT-09 restore request: send a batch of blinded messages we may have
* previously asked the mint to sign. The mint echoes back the subset of
* those it has indeed signed so we can unblind them.
*/
@Serializable
data class RestoreRequestDto(
val outputs: List<BlindedMessageDto>,
)
/**
* NUT-09 restore response: parallel arrays of original outputs the mint
* recognised plus the signatures it issued for them. Length of [outputs]
* == length of [signatures] and indexes correspond; the mint omits any
* blinded messages it never signed.
*/
@Serializable
data class RestoreResponseDto(
val outputs: List<BlindedMessageDto>,
val signatures: List<BlindSignatureDto>,
)
@Serializable
data class BlindSignatureDto(
val amount: Long,
@@ -0,0 +1,211 @@
/*
* 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.seed
import com.vitorpamplona.quartz.nip01Core.core.hexToByteArray
import com.vitorpamplona.quartz.nip06KeyDerivation.Bip32SeedDerivation
import com.vitorpamplona.quartz.nip06KeyDerivation.Hardener
import com.vitorpamplona.quartz.nip06KeyDerivation.KeyPath
import com.vitorpamplona.quartz.utils.mac.MacInstance
/**
* NUT-13 deterministic secret and blinding-factor derivation.
*
* Without NUT-13, every secret and blinding factor is freshly random, which
* means losing the kind:7375 token events (rogue relay, NIP-09 from a
* compromised key) is permanent loss of funds: the mint has the proofs but
* we can't ask for them back because we don't know what we asked for. With
* NUT-13, every (secret, r) pair is derived deterministically from a single
* seed plus a per-keyset counter, so the wallet can re-derive past secrets
* and ask the mint to return any blind signatures it still holds via
* NUT-09 /v1/restore.
*
* The seed input is the standard BIP-39 root seed (64 bytes from
* [Bip39Mnemonics.toSeed]).
*
* Two derivation paths, branching on keyset-id version (first byte):
*
* - **v00 keysets** (8-byte id, hex starts with `00`): BIP-32 hardened
* derivation at
* `m/129372'/0'/{keyset_id_int}'/{counter}'/{leaf}`
* where leaf is 0 for the secret, 1 for the blinding factor.
* `keyset_id_int` is the keyset's hex parsed as a big-endian integer
* mod 2^31 - 1 (fits a 31-bit BIP-32 hardened child index).
*
* - **v01 keysets** (33-byte id, hex starts with `01`): HMAC-SHA256
* derivation directly off the seed — BIP-32 hardened indices are 32
* bits, which can't faithfully encode a 33-byte id, so the protocol
* swaps to HMAC for v01. Spec:
* ```
* base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
* secret = HMAC_SHA256(seed, base || 0x00)
* r = HMAC_SHA256(seed, base || 0x01)
* ```
*
* 129372' is the UTF-8 codepoint for 🥜 ("peanuts"); coin-type 0' is fixed
* by the spec.
*
* Output: the 32-byte private key at the derived path is the secret /
* blinding factor directly. Per NUT-13 §1, the SECRET — which goes into
* the kind:7375 token JSON — is the lowercase hex string of those 32 bytes
* (utf-8 encoded), NOT the raw bytes. [secretBytes] returns the raw bytes;
* [secretAsAscii] wraps the hex encoding the wallet ships on the wire.
*/
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()
/**
* 32 raw bytes of the derived secret. The Cashu protocol uses these
* bytes as the ASCII-hex string they encode to — see [secretAsAscii].
*/
fun secretBytes(
seed: ByteArray,
keysetId: String,
counter: Long,
): ByteArray = derive(seed, keysetId, counter, leaf = 0L)
/**
* NUT-13 §1: "The secret is the UTF-8 encoded hex-string". This is
* what goes into the kind:7375 proof `secret` field and what the
* mint hashes in hash-to-curve.
*/
fun secretAsAscii(
seed: ByteArray,
keysetId: String,
counter: Long,
): ByteArray = bytesToLowercaseHex(secretBytes(seed, keysetId, counter)).encodeToByteArray()
/**
* 32 raw bytes of the BDHKE blinding factor `r`. Used as the second
* argument to [com.vitorpamplona.quartz.nip60Cashu.bdhke.Bdhke.blind].
*/
fun blindingFactor(
seed: ByteArray,
keysetId: String,
counter: Long,
): ByteArray = derive(seed, keysetId, counter, leaf = 1L)
/**
* Spec: `int.from_bytes(bytes.fromhex(keyset_id_hex), "big") % (2^31 - 1)`.
* Modulo by Mersenne-31 keeps the result inside a 32-bit BIP-32 hardened
* index (top bit is the hardened flag, so only 31 bits are usable).
*/
fun keysetIdToInt(keysetId: String): Long {
// BigInteger isn't available in commonMain; the keyset id is at most
// 33 bytes today (1 version byte + 32 hex chars for v01). Compute the
// mod-(2^31-1) incrementally — `acc = (acc * 256 + byte) % M` keeps
// every intermediate in Long range.
val bytes = keysetId.hexToByteArray()
val mersenne31 = 0x7FFFFFFFL
var acc = 0L
for (b in bytes) {
acc = (acc * 256L + (b.toLong() and 0xFFL)) % mersenne31
}
return acc
}
private fun derive(
seed: ByteArray,
keysetId: String,
counter: Long,
leaf: Long,
): ByteArray {
require(seed.isNotEmpty()) { "Seed cannot be empty" }
require(counter >= 0L) { "Counter must be non-negative" }
require(leaf == 0L || leaf == 1L) { "Leaf must be 0 (secret) or 1 (blinding factor)" }
val keysetIdBytes = keysetId.hexToByteArray()
// First byte is the version flag — v00 keysets use BIP-32 hardened
// derivation; v01 uses HMAC-SHA256 because a 33-byte keyset id
// doesn't fit a 31-bit BIP-32 child index without lossy modular
// collapse. Treat unknown / missing version as v00 for forwards-
// compat with any short legacy ids.
val isV01 = keysetIdBytes.isNotEmpty() && keysetIdBytes[0].toInt() == 0x01
return if (isV01) {
deriveV01(seed, keysetIdBytes, counter, leaf)
} else {
deriveV00(seed, keysetId, counter, leaf)
}
}
private fun deriveV00(
seed: ByteArray,
keysetId: String,
counter: Long,
leaf: Long,
): ByteArray {
val keysetInt = keysetIdToInt(keysetId)
val path =
KeyPath(
listOf(
Hardener.hardened(CASHU_PURPOSE),
Hardener.hardened(CASHU_COIN_TYPE),
Hardener.hardened(keysetInt),
Hardener.hardened(counter),
leaf,
),
)
val derivation = Bip32SeedDerivation()
val master = derivation.generate(seed)
return derivation.derivePrivateKey(master, path)
}
private fun deriveV01(
seed: ByteArray,
keysetIdBytes: ByteArray,
counter: Long,
leaf: Long,
): ByteArray {
// base = "Cashu_KDF_HMAC_SHA256" || keyset_id || counter_be64
val counterBytes = ByteArray(8)
for (i in 0 until 8) {
counterBytes[7 - i] = ((counter ushr (i * 8)) and 0xFFL).toByte()
}
val baseLen = V01_HMAC_PREFIX.size + keysetIdBytes.size + counterBytes.size
val message = ByteArray(baseLen + 1)
V01_HMAC_PREFIX.copyInto(message, 0)
keysetIdBytes.copyInto(message, V01_HMAC_PREFIX.size)
counterBytes.copyInto(message, V01_HMAC_PREFIX.size + keysetIdBytes.size)
message[baseLen] = leaf.toByte() // 0x00 for secret, 0x01 for blinding factor
val mac = MacInstance("HmacSHA256", seed)
mac.update(message)
return mac.doFinal()
}
/**
* Lowercase 0-9a-f without StringBuilder allocs in inner loops —
* matches what the spec demands (`bytes.hex()` in the Python ref impl).
*/
private fun bytesToLowercaseHex(bytes: ByteArray): String {
val chars = CharArray(bytes.size * 2)
for (i in bytes.indices) {
val v = bytes[i].toInt() and 0xFF
chars[i * 2] = HEX_CHARS[v ushr 4]
chars[i * 2 + 1] = HEX_CHARS[v and 0xF]
}
return String(chars)
}
private val HEX_CHARS = charArrayOf('0', '1', '2', '3', '4', '5', '6', '7', '8', '9', 'a', 'b', 'c', 'd', 'e', 'f')
}
@@ -0,0 +1,199 @@
/*
* 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.seed
import com.vitorpamplona.quartz.nip01Core.core.toHexKey
import com.vitorpamplona.quartz.nip06KeyDerivation.Bip39Mnemonics
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotEquals
/**
* NUT-13 test vectors verbatim from
* https://github.com/cashubtc/nuts/blob/main/tests/13-tests.md
* Identical inputs MUST produce identical outputs — these vectors are the
* cross-wallet recovery contract.
*/
class CashuDeterministicTest {
private val mnemonic = "half depart obvious quality work element tank gorilla view sugar picture humble"
private val seed by lazy { Bip39Mnemonics.toSeed(mnemonic, passphrase = "") }
// ============================================================
// Version 1 keyset (00 prefix, 16 hex chars)
// ============================================================
private val v1KeysetId = "009a1f293253e41e"
@Test
fun v1Counter0() {
assertEquals(
"485875df74771877439ac06339e284c3acfcd9be7abf3bc20b516faeadfe77ae",
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
)
assertEquals(
"ad00d431add9c673e843d4c2bf9a778a5f402b985b8da2d5550bf39cda41d679",
CashuDeterministic.blindingFactor(seed, v1KeysetId, 0L).toHexKey(),
)
}
@Test
fun v1Counter1() {
assertEquals(
"8f2b39e8e594a4056eb1e6dbb4b0c38ef13b1b2c751f64f810ec04ee35b77270",
CashuDeterministic.secretBytes(seed, v1KeysetId, 1L).toHexKey(),
)
assertEquals(
"967d5232515e10b81ff226ecf5a9e2e2aff92d66ebc3edf0987eb56357fd6248",
CashuDeterministic.blindingFactor(seed, v1KeysetId, 1L).toHexKey(),
)
}
@Test
fun v1Counter2() {
assertEquals(
"bc628c79accd2364fd31511216a0fab62afd4a18ff77a20deded7b858c9860c8",
CashuDeterministic.secretBytes(seed, v1KeysetId, 2L).toHexKey(),
)
assertEquals(
"b20f47bb6ae083659f3aa986bfa0435c55c6d93f687d51a01f26862d9b9a4899",
CashuDeterministic.blindingFactor(seed, v1KeysetId, 2L).toHexKey(),
)
}
@Test
fun v1Counter3() {
assertEquals(
"59284fd1650ea9fa17db2b3acf59ecd0f2d52ec3261dd4152785813ff27a33bf",
CashuDeterministic.secretBytes(seed, v1KeysetId, 3L).toHexKey(),
)
assertEquals(
"fb5fca398eb0b1deb955a2988b5ac77d32956155f1c002a373535211a2dfdc29",
CashuDeterministic.blindingFactor(seed, v1KeysetId, 3L).toHexKey(),
)
}
@Test
fun v1Counter4() {
assertEquals(
"576c23393a8b31cc8da6688d9c9a96394ec74b40fdaf1f693a6bb84284334ea0",
CashuDeterministic.secretBytes(seed, v1KeysetId, 4L).toHexKey(),
)
assertEquals(
"5f09bfbfe27c439a597719321e061e2e40aad4a36768bb2bcc3de547c9644bf9",
CashuDeterministic.blindingFactor(seed, v1KeysetId, 4L).toHexKey(),
)
}
// ============================================================
// Version 2 keyset (01 prefix, 33 bytes / 66 hex chars)
// ============================================================
private val v2KeysetId = "015ba18a8adcd02e715a58358eb618da4a4b3791151a4bee5e968bb88406ccf76a"
@Test
fun v2Counter0() {
assertEquals(
"db5561a07a6e6490f8dadeef5be4e92f7cebaecf2f245356b5b2a4ec40687298",
CashuDeterministic.secretBytes(seed, v2KeysetId, 0L).toHexKey(),
)
assertEquals(
"6d26181a3695e32e9f88b80f039ba1ae2ab5a200ad4ce9dbc72c6d3769f2b035",
CashuDeterministic.blindingFactor(seed, v2KeysetId, 0L).toHexKey(),
)
}
@Test
fun v2Counter4() {
assertEquals(
"5e89fc5d30d0bf307ddf0a3ac34aa7a8ee3702169dafa3d3fe1d0cae70ecd5ef",
CashuDeterministic.secretBytes(seed, v2KeysetId, 4L).toHexKey(),
)
assertEquals(
"5550337312d223ba62e3f75cfe2ab70477b046d98e3e71804eade3956c7b98cf",
CashuDeterministic.blindingFactor(seed, v2KeysetId, 4L).toHexKey(),
)
}
// ============================================================
// Cross-checks — sanity properties
// ============================================================
@Test
fun secretAndBlindingDiffer() {
// Leaf index 0 vs 1 must produce different outputs even for the same
// (seed, keyset, counter) — otherwise the same value is used as both
// the secret AND the blinding factor, a security disaster.
assertNotEquals(
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
CashuDeterministic.blindingFactor(seed, v1KeysetId, 0L).toHexKey(),
)
}
@Test
fun counterAdvanceChangesOutput() {
assertNotEquals(
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
CashuDeterministic.secretBytes(seed, v1KeysetId, 1L).toHexKey(),
)
}
@Test
fun differentKeysetsProduceDifferentSecrets() {
assertNotEquals(
CashuDeterministic.secretBytes(seed, v1KeysetId, 0L).toHexKey(),
CashuDeterministic.secretBytes(seed, v2KeysetId, 0L).toHexKey(),
)
}
@Test
fun secretAsAsciiIsLowercaseHexOfBytes() {
val bytes = CashuDeterministic.secretBytes(seed, v1KeysetId, 0L)
val ascii = CashuDeterministic.secretAsAscii(seed, v1KeysetId, 0L)
assertEquals(bytes.toHexKey(), ascii.decodeToString())
// Belt-and-braces: result must be exactly 64 lowercase hex chars (32 bytes * 2).
assertEquals(64, ascii.size)
}
// ============================================================
// keysetIdToInt — the modular-reduction helper
// ============================================================
@Test
fun keysetIdToIntZero() {
assertEquals(0L, CashuDeterministic.keysetIdToInt("00"))
}
@Test
fun keysetIdToIntSmall() {
// 0x12 = 18
assertEquals(18L, CashuDeterministic.keysetIdToInt("12"))
}
@Test
fun keysetIdToIntFitsIn31Bits() {
// All 0xFF bytes ⇒ value mod (2^31 - 1) is always < 2^31 - 1.
val v1 = CashuDeterministic.keysetIdToInt("ffffffffffffffff")
val v2 = CashuDeterministic.keysetIdToInt("ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff")
// Strict less than 2^31 - 1.
assertEquals(true, v1 in 0..0x7FFFFFFEL)
assertEquals(true, v2 in 0..0x7FFFFFFEL)
}
}
@@ -88,6 +88,8 @@ class MintHttpClient(
suspend fun checkState(request: CheckStateRequestDto): CheckStateResponseDto = post("/v1/checkstate", request, CheckStateRequestDto.serializer())
suspend fun restore(request: RestoreRequestDto): RestoreResponseDto = post("/v1/restore", request, RestoreRequestDto.serializer())
private suspend inline fun <reified R> get(path: String): R =
withContext(Dispatchers.IO) {
val url = baseUrl + path