diff --git a/README.md b/README.md index a4d77b6cf..ccdd5df7c 100644 --- a/README.md +++ b/README.md @@ -55,6 +55,7 @@ We provide comprehensive examples categorized by use case: - **[WalletOps](./docs-src/wallet_ops/wallet_ops.md)**: Translation Builder Usage Recipes. - **[WalletEvents](./docs-src/wallet_events/wallet_events.md)**: Event Subscriptions - **[Deterministic Counters](./docs-src/deterministic_counters.md)**: Deterministic counters (persist, inspect, bump). +- **[Derive Keys](./docs-src/usage/derive_keys.md)**: Derive recoverable P2PK / NUT-20 keys from the wallet seed. ### Note: Builder hooks vs Global events diff --git a/docs-src/usage/create_p2pk.md b/docs-src/usage/create_p2pk.md index bee2b9a08..7083c6498 100644 --- a/docs-src/usage/create_p2pk.md +++ b/docs-src/usage/create_p2pk.md @@ -18,3 +18,6 @@ const privkey = '5d...'; // private key for pubkey const receiveProofs = await wallet2.receive(token, {privkey}); // store receiveProofs in your app .. ``` + +> Tip: derive `pubkey`/`privkey` deterministically from the wallet seed so the lock key is +> recoverable without a separate backup. See [Derive Keys](./derive_keys.md). diff --git a/docs-src/usage/derive_keys.md b/docs-src/usage/derive_keys.md new file mode 100644 index 000000000..81a4aaec2 --- /dev/null +++ b/docs-src/usage/derive_keys.md @@ -0,0 +1,74 @@ +# Documents › [Usage Examples](../usage/usage_index.md) › **Derive Keys** + +# Derive deterministic P2PK & quote-locking keys + +Derive secp256k1 signing keys deterministically from the wallet seed, so the keys you lock +proofs (NUT-11 P2PK) or mint quotes (NUT-20) to are **recoverable**: no separate key backup, +just the seed. Keys follow the BIP-32 path `m/129373'/{purpose}'/0'/0'/{counter}`, as defined for +[NUT-11](https://github.com/cashubtc/nuts/blob/main/11.md) (P2PK) and +[NUT-20](https://github.com/cashubtc/nuts/blob/main/20.md) (quote locking). + +```typescript +import { deriveKeyPair } from '@cashu/cashu-ts'; + +// `seed` is the same Uint8Array you passed to `new Wallet(url, { bip39seed: seed })`. +// `purpose` is 'P2PK' or 'QuoteLock'; `counter` is yours to allocate and persist (see "Counters"). +const { pubkey, privkey } = deriveKeyPair(seed, 'P2PK', counter); // both hex strings +``` + +`pubkey` and `privkey` are hex, so they drop straight into the lock/quote/sign APIs with no +conversion. + +## P2PK: lock a send, recover the key to receive it + +```typescript +import { deriveKeyPair, getEncodedToken } from '@cashu/cashu-ts'; + +const counter = 0; // your next unused P2PK counter +const { pubkey, privkey } = deriveKeyPair(seed, 'P2PK', counter); + +const { send } = await wallet.ops.send(32, proofs).asP2PK({ pubkey }).run(); +const token = getEncodedToken({ mint: mintUrl, proofs: send }); + +// Later: re-derive the same key from seed + counter to unlock the proofs: +const { privkey: recovered } = deriveKeyPair(seed, 'P2PK', counter); +const receiveProofs = await wallet.receive(token, { privkey: recovered }); +``` + +## NUT-20: lock a mint quote + +```typescript +import { deriveKeyPair } from '@cashu/cashu-ts'; + +const counter = 0; // your next unused quote-lock counter +const { pubkey, privkey } = deriveKeyPair(seed, 'QuoteLock', counter); + +const quote = await wallet.createLockedMintQuote(64, pubkey); +// ...pay the quote's BOLT11 invoice... +const proofs = await wallet.ops.mint(64, quote).privkey(privkey).run(); +``` + +## Counters + +A counter only tells you _how many_ keys exist, never _what each was for_, so this library does +**not** track them. You own the allocation and the mapping (`quote -> counter`, +`proof/pubkey -> counter`) in your app storage. Use the next unused integer for each new key, and +persist it alongside whatever the key locks. + +## Restore scans + +To find which counter a recovered pubkey belongs to, scan counters and match. For tight loops use +`createKeyPairDeriver`, which caches the shared parent derivation (one child derivation per counter +instead of re-walking the full path): + +```typescript +import { createKeyPairDeriver } from '@cashu/cashu-ts'; + +const derive = createKeyPairDeriver(seed, 'P2PK'); // cached; returns (counter) => { pubkey, privkey } +for (let counter = 0; counter < gapLimit; counter++) { + if (derive(counter).pubkey === targetPubkey) { + // matched: counter found + break; + } +} +``` diff --git a/docs-src/usage/usage_index.md b/docs-src/usage/usage_index.md index 6c12fe238..139a434f3 100644 --- a/docs-src/usage/usage_index.md +++ b/docs-src/usage/usage_index.md @@ -27,6 +27,7 @@ If you are building a wallet integration from scratch, read these in order: | [Mint Token](./mint_token.md) | Create proofs from a paid quote, including two-step mint flows. | | [Create Token](./create_token.md) | Send standard Cashu tokens to another wallet. | | [Create P2PK](./create_p2pk.md) | Send tokens locked to a public key. | +| [Derive Keys](./derive_keys.md) | Derive recoverable P2PK / NUT-20 keys deterministically from the wallet seed. | | [Get Token](./get_token.md) | Inspect token metadata before wallet creation or decode it after load. | | [Melt Token](./melt_token.md) | Pay BOLT11 invoices or other payment methods with wallet proofs. | | [Bolt12](./bolt12.md) | Work with reusable BOLT12 offers for minting and melting. | diff --git a/etc/cashu-ts.api.md b/etc/cashu-ts.api.md index 979c288a3..2e9cab84f 100644 --- a/etc/cashu-ts.api.md +++ b/etc/cashu-ts.api.md @@ -219,6 +219,9 @@ export function batchVerifyUnblindedSignatureBls(items: Array<{ secret: Uint8Array; }>): boolean; +// @public +export type Bip32KeyPurpose = 'P2PK' | 'QuoteLock'; + // @public export function blindMessage(secret: Uint8Array, r?: bigint): RawBlindedMessage; @@ -352,6 +355,12 @@ export function createHTLCHash(preimage?: string): { // @public export function createHTLCsecret(hash: string, tags?: string[][]): string; +// @public +export function createKeyPairDeriver(seed: Uint8Array, purpose: Bip32KeyPurpose): (counter: number) => { + pubkey: string; + privkey: string; +}; + // @public export function createNewMintKeys(pow2height: IntRange<0, 65>, seed?: Uint8Array, options?: { expiry?: number; @@ -393,6 +402,12 @@ export type CurvePoint = { // @public export function decodePaymentRequest(paymentRequest: string): PaymentRequest_2; +// @public +export function deriveKeyPair(seed: Uint8Array, purpose: Bip32KeyPurpose, counter: number): { + pubkey: string; + privkey: string; +}; + // @public export function deriveKeysetId(keys: Keys, options?: DeriveKeysetIdOptions): string; diff --git a/src/crypto/NUT13.ts b/src/crypto/NUT13.ts index 43ddd4d0c..e84d57a7a 100644 --- a/src/crypto/NUT13.ts +++ b/src/crypto/NUT13.ts @@ -1,4 +1,4 @@ -import { numberToBytesBE } from '@noble/curves/utils.js'; +import { bytesToHex, numberToBytesBE } from '@noble/curves/utils.js'; import { hmac } from '@noble/hashes/hmac.js'; import { sha256 } from '@noble/hashes/sha2.js'; import { HDKey } from '@scure/bip32'; @@ -7,10 +7,28 @@ import { CTSError } from '../model/Errors'; import { Bytes, isBase64String } from '../utils'; import { BLS_FR_ORDER } from './curve_bls'; +import { getPubKeyFromPrivKey } from './curve_secp'; import { getKeysetIdInt, isBlsKeyset } from './curves'; const STANDARD_DERIVATION_PATH = `m/129372'/0'`; +/** + * Purpose of a deterministically-derived key, selecting the index in the BIP-32 path + * `m/129373'/{index}'/0'/0'/{counter}`. + * + * - `P2PK`: NUT-11 P2PK signing key. + * - `QuoteLock`: NUT-20 quote locking key. + */ +export type Bip32KeyPurpose = 'P2PK' | 'QuoteLock'; + +/** + * Path purpose index per {@link Bip32KeyPurpose}. + */ +const PURPOSE_INDEX: Record = { + P2PK: 10, + QuoteLock: 20, +}; + const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141'); enum DerivationKind { @@ -48,6 +66,70 @@ export function deriveSecretAndBlindingFactor( return derive(counter); } +/** + * Derives the deterministic keypair for one counter under the BIP-32 path + * `m/129373'/{purpose}'/0'/0'/{counter}` (the counter child is non-hardened). + * + * @remarks + * Used for NUT-11 P2PK keys and NUT-20 quote locking keys. Both fields are hex: `pubkey` drops into + * the lock/quote APIs and `privkey` into `signP2PKProofs`. To scan many counters from the same + * seed, prefer {@link createKeyPairDeriver}, which caches the shared parent. + * + * The counter child is non-hardened so the parent xpub can derive counter pubkeys for watch-only + * use. Consequently, never export the parent xpub alongside any counter's private key: with both, + * the parent private key (and thus every counter's key) can be recovered. + * @param seed - Wallet seed used for deterministic derivation. + * @param purpose - Key purpose (`'P2PK'` or `'QuoteLock'`), which selects the path's purpose index. + * @param counter - Non-hardened BIP-32 child index. + * @returns The derived keypair, both hex-encoded: compressed (02/03) `pubkey` and `privkey`. + * @throws {@link CTSError} If the counter is not a non-hardened index (integer below 2^31) or + * derivation produces an invalid private key. + */ +export function deriveKeyPair( + seed: Uint8Array, + purpose: Bip32KeyPurpose, + counter: number, +): { pubkey: string; privkey: string } { + const derive = createKeyPairDeriver(seed, purpose); + return derive(counter); +} + +/** + * Creates a deterministic keypair deriver for a seed/purpose pair. + * + * @remarks + * Caches the parent `m/129373'/{purpose}'/0'/0'` derivation once so each per-counter call is a + * single non-hardened child derivation. This is ~5x faster than re-traversing the full path per + * counter, so it is the path to use for restore loops scanning many counters. Each call returns a + * ready-to-use hex keypair; for a single counter use {@link deriveKeyPair}. + * + * The counter child is non-hardened so the parent xpub can derive counter pubkeys for watch-only + * use. Consequently, never export the parent xpub alongside any counter's private key: with both, + * the parent private key (and thus every counter's key) can be recovered. + * @param seed - Wallet seed used for deterministic derivation. + * @param purpose - Key purpose, which selects the path's purpose index. + * @returns A function mapping a non-hardened counter to its hex keypair. + */ +export function createKeyPairDeriver( + seed: Uint8Array, + purpose: Bip32KeyPurpose, +): (counter: number) => { pubkey: string; privkey: string } { + const index = PURPOSE_INDEX[purpose]; + const parentKey = HDKey.fromMasterSeed(seed).derive(`m/129373'/${index}'/0'/0'`); + return (counter: number) => { + // deriveChild silently hardens indices >= 2^31, which xpub-only derivation cannot follow. + if (!Number.isInteger(counter) || counter < 0 || counter >= 0x80000000) { + throw new CTSError('Counter must be a non-hardened BIP-32 index (0 <= counter < 2^31)'); + } + const secretKey = parentKey.deriveChild(counter).privateKey; + /* c8 ignore next */ + if (secretKey === null) { + throw new CTSError('Could not derive secret key'); + } + return { pubkey: bytesToHex(getPubKeyFromPrivKey(secretKey)), privkey: bytesToHex(secretKey) }; + }; +} + // ------------------------------ // Internal helpers // ------------------------------ diff --git a/test/crypto/NUT13-p2pk.test.ts b/test/crypto/NUT13-p2pk.test.ts new file mode 100644 index 000000000..ffd0fcbd5 --- /dev/null +++ b/test/crypto/NUT13-p2pk.test.ts @@ -0,0 +1,61 @@ +import { hexToBytes } from '@noble/hashes/utils.js'; +import { describe, expect, test } from 'vitest'; + +import { type Bip32KeyPurpose, createKeyPairDeriver, deriveKeyPair } from '../../src/crypto'; + +// BIP39 seed (no passphrase) for the mnemonic in the NUT-11 / NUT-20 test vectors: +// "half depart obvious quality work element tank gorilla view sugar picture humble" +const SEED = hexToBytes( + 'dd44ee516b0647e80b488e8dcc56d736a148f15276bef588b37057476d4b2b25' + + '780d3688a32b37353d6995997842c0fd8b412475c891c16310471fbc86dcbda8', +); + +// Expected compressed (02/03-prefixed) public keys from the PR test vectors. +const VECTORS: Record = { + P2PK: [ + '021693d45f4fdf610ae641fedb0944fb460fbb8264f21c19d2626c3da755fcbbcb', + '0395461ab678058c0ed6aa39f38dda490eaa163e9ad27070b23ec3d06b41e07535', + '02a05e4e593a633e9b4405f01c9632c8afde24cb613017a1aee56fd76291ad26d1', + '033addea25c3873b93d67d536c61c9d9c993f6efd8b9dfa657951b66b5001e51dd', + '03c964bdf42fc82b6c574615746eeca37527a24f1fdfc1b34a732c53843b5744a5', + ], + QuoteLock: [ + '03062837166e56114b59a4d1fd3a5a812bf7aadc1dde758428cf943d80acd41539', + '02b47d9d41725f5ce6f08c874835cef25376cb1e95f6cb073fef52ca8fd986cf15', + '029acbd3a46fd75bc05ba0226d0b4d909b2fb6e96c80544a094a1a3567737e44d3', + '0373e4a42fbe0a4e18aadb57cf500b655f2446b4071ee579121d2ed8905bcc49c2', + '02b8709bfce17c10f1864f5218844533ae60930d52089669b317d8b5f474eec071', + ], +}; + +describe('deterministic P2PK / quote-lock key derivation (NUT-11, NUT-20)', () => { + test.each(['P2PK', 'QuoteLock'] as const)( + '%s matches spec test vectors for counters 0-4', + (purpose) => { + const expected = VECTORS[purpose]; + const derive = createKeyPairDeriver(SEED, purpose); + for (let counter = 0; counter < expected.length; counter++) { + const pair = deriveKeyPair(SEED, purpose, counter); + expect(pair.privkey).toHaveLength(64); // 32 bytes, hex + expect(pair.pubkey).toBe(expected[counter]); + // cached factory must agree with the one-shot keypair + expect(derive(counter)).toEqual(pair); + } + }, + ); + + test('P2PK and QuoteLock purposes diverge for the same seed/counter', () => { + expect(deriveKeyPair(SEED, 'P2PK', 0).pubkey).not.toBe( + deriveKeyPair(SEED, 'QuoteLock', 0).pubkey, + ); + }); + + // deriveChild would silently harden indices >= 2^31, breaking xpub watch-only derivation + test.each([0x80000000, -1, 1.5, Number.NaN])('rejects invalid counter %s', (counter) => { + expect(() => deriveKeyPair(SEED, 'P2PK', counter)).toThrow('non-hardened'); + }); + + test('accepts the maximum non-hardened counter', () => { + expect(deriveKeyPair(SEED, 'P2PK', 0x7fffffff).pubkey).toMatch(/^0[23][0-9a-f]{64}$/); + }); +});