Skip to content
Merged
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
3 changes: 3 additions & 0 deletions docs-src/usage/create_p2pk.md
Original file line number Diff line number Diff line change
Expand Up @@ -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).
74 changes: 74 additions & 0 deletions docs-src/usage/derive_keys.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
# <a href="/">Documents</a> › [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;
}
}
```
1 change: 1 addition & 0 deletions docs-src/usage/usage_index.md
Original file line number Diff line number Diff line change
Expand Up @@ -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. |
Expand Down
15 changes: 15 additions & 0 deletions etc/cashu-ts.api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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;

Expand Down
84 changes: 83 additions & 1 deletion src/crypto/NUT13.ts
Original file line number Diff line number Diff line change
@@ -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';
Expand All @@ -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<Bip32KeyPurpose, number> = {
P2PK: 10,
QuoteLock: 20,
};

const SECP256K1_N = BigInt('0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141');

enum DerivationKind {
Expand Down Expand Up @@ -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
// ------------------------------
Expand Down
61 changes: 61 additions & 0 deletions test/crypto/NUT13-p2pk.test.ts
Original file line number Diff line number Diff line change
@@ -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<Bip32KeyPurpose, string[]> = {
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}$/);
});
});
Loading