Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions 11.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`
Expand Down Expand Up @@ -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
3 changes: 3 additions & 0 deletions 20.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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
112 changes: 112 additions & 0 deletions XX.md
Original file line number Diff line number Diff line change
@@ -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<n>`.

## 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
47 changes: 47 additions & 0 deletions tests/XX-tests.md
Original file line number Diff line number Diff line change
@@ -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"
}
```
Loading