diff --git a/11.md b/11.md index a529d414..1df9a3d9 100644 --- a/11.md +++ b/11.md @@ -12,6 +12,8 @@ This NUT describes Pay-to-Public-Key (P2PK) which is one kind of spending condit Caution: If the mint does not support this type of spending condition, Proofs may be treated as regular anyone-can-spend Proofs. Applications need to make sure to check whether the mint supports a specific kind of spending condition by checking the mint's [NUT-06][06] info endpoint. +Wallets **MAY** derive P2PK private keys deterministically from their seed using [NUT-XX][XX]. + ## Basic Case [NUT-10][10] Secret `kind: P2PK` @@ -288,3 +290,4 @@ The [NUT-06][06] `MintMethodSetting` indicates support for this feature: [10]: 10.md [11]: 11.md [12]: 12.md +[XX]: XX.md diff --git a/20.md b/20.md index 4bf1b598..588d0a16 100644 --- a/20.md +++ b/20.md @@ -39,6 +39,8 @@ with the requested `amount`,`unit`, and `description` according to [NUT-04][04]. > > **Privacy:** To prevent the mint from being able to link multiple mint quotes, wallets **SHOULD** generate a unique public key for each mint quote request. +Wallets **MAY** derive the quote locking keypair deterministically from their seed using [NUT-XX][XX]. + The mint `Bob` then responds with a `PostMintQuoteBolt11Response`: ```json @@ -187,4 +189,5 @@ The settings for this NUT indicate the support for requiring a signature before [00]: 00.md [04]: 04.md [06]: 06.md +[XX]: XX.md [errors]: error_codes.md diff --git a/README.md b/README.md index ea65402d..4fcc6543 100644 --- a/README.md +++ b/README.md @@ -46,6 +46,7 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio | [28][28] | Pay to Blinded Key (P2BK) | [cdk], [cashu-ts][ts] | - | | [29][29] | Batched Mint | - | - | | [30][30] | Payment Method: Onchain | - | - | +| [XX][XX] | Deterministic Keypairs | - | - | #### Wallets @@ -105,3 +106,4 @@ Wallets and mints `MUST` implement all mandatory specs and `CAN` implement optio [28]: 28.md [29]: 29.md [30]: 30.md +[XX]: XX.md diff --git a/XX.md b/XX.md new file mode 100644 index 00000000..87a2c4b3 --- /dev/null +++ b/XX.md @@ -0,0 +1,112 @@ +# NUT-XX: Deterministic Keypairs + +`optional` + +--- + +In this document, we describe how wallets deterministically derive secp256k1 keypairs from their seed for use as locking keys in spending conditions (e.g. [NUT-11][11] P2PK) and as quote locking keys (e.g. [NUT-20][20]). This allows wallets to recover the private keys needed to spend locked ecash and to sign quote-locked mint requests after a restore. + +## Derivation + +The wallet uses the `seed` derived from a [BIP39][bip39] mnemonic, a domain separation tag `DST`, and a `counter` to derive each private key. A separate `counter` is kept per domain. + +The HMAC-SHA256 KDF is built as the following: + +1. `message = DST || counter_bytes`, where: + - `DST` is the UTF-8 encoded domain separation tag (see [Domains](#domains)). + - `counter_bytes` is the counter encoded as an unsigned 64-bit integer in big-endian format. +2. `hmac_digest = HMAC_SHA256(seed, message)`. +3. `priv_key = OS2IP(hmac_digest) mod N`, where `N` is the secp256k1 group order and `OS2IP` interprets the digest as an unsigned big-endian integer. +4. If `priv_key == 0`, the derivation **MUST** be rejected. The probability is negligible. +5. The wallet derives the compressed secp256k1 public key from `priv_key` and uses it as the locking pubkey for the relevant domain. + +## Domains + +Each application gets a distinct DST and an independent `counter`. Wallets **MUST NOT** share counters across domains. + +| Domain | DST | +| ----------------------- | ----------------------------- | +| [NUT-11][11] P2PK keys | `Cashu_KDF_HMAC_SHA256_NUT11` | +| [NUT-20][20] quote keys | `Cashu_KDF_HMAC_SHA256_NUT20` | + +Future NUTs that require deterministic wallet keys **SHOULD** register a new domain here with a distinct DST of the form `Cashu_KDF_HMAC_SHA256_NUT`. + +## Counter + +For each domain, the wallet starts with `counter := 0` and increments it by `1` every time it consumes a derived key. The wallet stores the latest `counter` per domain in its database. + +## Restoring keys + +To recover keys after a restore, the wallet iterates `counter` from `0` for each domain and derives candidate public keys until it has found all keys that were used. Because locked proofs and quote pubkeys carry the terminal pubkey only (no derivation marker), recovery requires deriving candidate keys and matching them against on-mint state (locked proofs found via [NUT-09][09] restore, mint quotes queried by the wallet). + +Wallets **SHOULD** scan in batches and continue until three consecutive batches yield no matches, mirroring the [NUT-13][13] approach. + +## Code examples + +Python: + +```python +import hmac +import hashlib + +SECP256K1_N = int( + "FFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", 16 +) + +def derive_priv_key(seed: bytes, dst: bytes, counter: int) -> bytes: + """ + Derive a 32-byte secp256k1 private key from seed for the given domain DST and counter. + """ + message = dst + counter.to_bytes(8, "big") + digest = hmac.new(seed, message, hashlib.sha256).digest() + x = int.from_bytes(digest, "big", signed=False) + k = x % SECP256K1_N + if k == 0: + raise RuntimeError("Derived invalid scalar k == 0") + return k.to_bytes(32, "big") + +# Example domains +DST_NUT11 = b"Cashu_KDF_HMAC_SHA256_NUT11" +DST_NUT20 = b"Cashu_KDF_HMAC_SHA256_NUT20" +``` + +TypeScript: + +```typescript +import * as crypto from "crypto"; + +const SECP256K1_N = BigInt( + "0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141", +); + +function derivePrivKey(seed: Buffer, dst: Buffer, counter: number): Buffer { + const counterBuffer = Buffer.alloc(8); + counterBuffer.writeBigUInt64BE(BigInt(counter)); + const message = Buffer.concat([dst, counterBuffer]); + + const digest = crypto.createHmac("sha256", seed).update(message).digest(); + const x = BigInt("0x" + digest.toString("hex")); + const k = x % SECP256K1_N; + + if (k === 0n) { + throw new Error("Derived invalid scalar k == 0"); + } + + return Buffer.from(k.toString(16).padStart(64, "0"), "hex"); +} + +// Example domains +const DST_NUT11 = Buffer.from("Cashu_KDF_HMAC_SHA256_NUT11"); +const DST_NUT20 = Buffer.from("Cashu_KDF_HMAC_SHA256_NUT20"); +``` + +The compressed secp256k1 public key derived from the returned private key is the value used as the locking pubkey. + +**Note:** See the [test vectors][tests]. + +[09]: 09.md +[11]: 11.md +[13]: 13.md +[20]: 20.md +[bip39]: https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki +[tests]: tests/XX-tests.md diff --git a/tests/XX-tests.md b/tests/XX-tests.md new file mode 100644 index 00000000..aaa621be --- /dev/null +++ b/tests/XX-tests.md @@ -0,0 +1,47 @@ +# NUT-XX Test vectors + +## NUT-11 (P2PK) key derivation + +Using [NUT-XX](../XX.md) derivation with DST `Cashu_KDF_HMAC_SHA256_NUT11`, we derive values starting from the following BIP39 mnemonic: + +```json +{ + "mnemonic": "half depart obvious quality work element tank gorilla view sugar picture humble", + "seed_hex": "dd44ee516b0647e80b488e8dcc56d736a148f15276bef588b37057476d4b2b25780d3688a32b37353d6995997842c0fd8b412475c891c16310471fbc86dcbda8" +} +``` + +The compressed public keys derived for the first five counters from `counter=0` to `counter=4` are: + +```json +{ + "pubkey_0": "0202bf842ee2e4a3aba0728238bb073a9a3ab383695a58fd26e3f03f70bf671890", + "pubkey_1": "028adf592a715df71b60857b20c4ff5c3630e305432fd8ec6a02daf5f3beef3083", + "pubkey_2": "028932541412a283dd7c1c4e16ffe527d837425003d325c18fcb959cdaffdf9215", + "pubkey_3": "0348a2251a9752830363300b9ac6f8ff7201e1932a2bb72ba9e00d1685d0bee6c6", + "pubkey_4": "033fdbee6157b5e35e33c4d8541f42c0a7096dad480e44b0fb74818743ca45a275" +} +``` + +## NUT-20 (quote locking) key derivation + +Using [NUT-XX](../XX.md) derivation with DST `Cashu_KDF_HMAC_SHA256_NUT20`, we derive values starting from the following BIP39 mnemonic: + +```json +{ + "mnemonic": "half depart obvious quality work element tank gorilla view sugar picture humble", + "seed_hex": "dd44ee516b0647e80b488e8dcc56d736a148f15276bef588b37057476d4b2b25780d3688a32b37353d6995997842c0fd8b412475c891c16310471fbc86dcbda8" +} +``` + +The compressed public keys derived for the first five counters from `counter=0` to `counter=4` are: + +```json +{ + "pubkey_0": "020509f7911de3c948ba971938cee5f588d3d3a41c13bc7bb308823896c5778c32", + "pubkey_1": "023b614a85de226083698217bcf3b1f84d91c91846629491342b7134eb849af18a", + "pubkey_2": "03668b29ad91a1763afa773ade6920106d10edb3b378c2f2ddc0b8e65fd3b5e23c", + "pubkey_3": "0262a8ce41fe91e9856e29c0f447db4d968d5db55c2400eb5c5c2cd3cec80227dd", + "pubkey_4": "025f78058b39825e2e575a501319d8b3510387be6c7f9245037a38b76fa29cb12a" +} +```