Skip to content

Deterministic pay to public key generation#331

Open
lescuer97 wants to merge 4 commits into
cashubtc:mainfrom
lescuer97:p2pk_recovery
Open

Deterministic pay to public key generation#331
lescuer97 wants to merge 4 commits into
cashubtc:mainfrom
lescuer97:p2pk_recovery

Conversation

@lescuer97

@lescuer97 lescuer97 commented Jan 19, 2026

Copy link
Copy Markdown
Contributor

Adds a standard way to generate keys for wallets to generate private keys for usage in P2PK operations.

Comment thread 13.md
Comment thread 13.md Outdated
@robwoodgate

Copy link
Copy Markdown
Collaborator

Keysets v2 is moving away from a BIP32 style derivation scheme, and using a KDF instead. Would it not be better to align with this approach, eg using a domain separator like b"Cashu_KDF_HMAC_SHA256_P2PK" or b"Cashu_P2PK_v1"?

Also, I assume the counter is separate to the one used for secrets/blinded messages. If so, this should be made clearer.

Finally, related to @a1denvalu3's comment - what happens if the derived key is out of range? Should it be discarded (as per BIP-32) or reduced modulo N?

@prusnak

prusnak commented Jan 20, 2026

Copy link
Copy Markdown
Collaborator

Keysets v2 is moving away from a BIP32 style derivation scheme, and using a KDF instead. Would it not be better to align with this approach, eg using a domain separator like b"Cashu_KDF_HMAC_SHA256_P2PK" or b"Cashu_P2PK_v1"?

Good point 👍

@lescuer97

Copy link
Copy Markdown
Contributor Author

@robwoodgate @a1denvalu3 just changed the wording around the NUT so it's a bit more clear.

robwoodgate

This comment was marked as duplicate.

@robwoodgate robwoodgate left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion to tighten language, otherwise concept ACK

Comment thread 13.md Outdated
Comment thread 13.md Outdated
Comment thread 13.md
Comment thread 13.md Outdated

@robwoodgate robwoodgate left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Honestly, I think we should use NUT-13's v2 HMAC-SHA256 KDF rather than introducing a new BIP32 path.

The KDF defined for v2 exists precisely because BIP32 path traversal is really expensive, and a P2PK private key only needs a uniform secp256k1 scalar, which the same single-HMAC + single-subtraction reduction (bias ~2⁻¹²⁸) gives you directly.

Quick numbers from a pure-JS noble benchmark (cashu-ts, per counter):

Pattern                                          Cost/key   vs HMAC
HMAC-SHA256                                       ~165 µs    1.0×
BIP-32 parent-cached, HDKey.publicKey             ~172 µs    1.0×    natural restore-loop code
BIP-32 master-cached, full path per derive        ~862 µs    5.2×    natural single-derive code
BIP-32 cold, no caching                          ~1216 µs    7.4×    worst case

In a restore situation, the wallet can cache the parent path derivation and get close to parity with HMAC, but if using just a cached master key (for regular wallet ops), the slowdown is 5x. In other words, BIP-32 needs careful handling to avoid performance tanking.

There is also no special need for BIP32 here: P2PK keys are derived wallet-side from a hot seed, so there's no hardware-wallet or chain-code benefit.

Mirroring the v2 construction — something like:

HMAC_SHA256(seed, "Cashu_KDF_HMAC_SHA256_NUT11" || counter_u64_be) reduced mod n

would keep a single KDF across all of NUT-13 and lines up with the direction v2 was already moving.

@robwoodgate

This comment has been minimized.

@robwoodgate robwoodgate left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Following offline discussion, and the fact this is already live, I've closed #384.

@robwoodgate

Copy link
Copy Markdown
Collaborator

@robwoodgate robwoodgate requested review from callebtc and removed request for a1denvalu3 July 4, 2026 19:41
@ye0man ye0man requested review from Egge21M and a1denvalu3 July 7, 2026 13:56
robwoodgate added a commit to cashubtc/cashu-ts that referenced this pull request Jul 8, 2026
…697)

# NUTS:

- cashubtc/nuts#331
- cashubtc/nuts#373

## Summary

Adds BIP-32 derivation of deterministic signing keys under
`m/129373'/{purpose}'/0'/0'/{counter}` (non-hardened counter), as
defined for:

- [NUT-11](https://github.com/cashubtc/nuts/blob/main/11.md): P2PK keys
(purpose index `10`)
- [NUT-20](https://github.com/cashubtc/nuts/blob/main/20.md): quote
locking keys (purpose index `20`)

## API

```ts
export type Bip32KeyPurpose = 'P2PK' | 'QuoteLock';

deriveKeyPair(seed, purpose, counter): { pubkey: string; privkey: string }            // one-shot
createKeyPairDeriver(seed, purpose): (counter) => { pubkey: string; privkey: string } // cached, for loops
```

`purpose` is passed by name (`'P2PK'` / `'QuoteLock'`). Both return hex
`pubkey`/`privkey` that drop straight into the existing lock/quote and
`signP2PKProofs` APIs with zero conversion:

```ts
const { pubkey, privkey } = deriveKeyPair(seed, 'P2PK', counter);
await wallet.send(64, proofs, undefined, { send: { type: 'p2pk', options: { pubkey } } });
await wallet.send(64, lockedProofs, { privkey });

const { pubkey: qp, privkey: qk } = deriveKeyPair(seed, 'QuoteLock', counter);
const quote = await wallet.createLockedMintQuote(amount, qp);
await wallet.mintProofs('bolt11', amount, quote, { privkey: qk });
```

`createKeyPairDeriver` caches the shared parent derivation, so restore
scans cost a single non-hardened child derivation per counter (~5x
faster than re-walking the full path) while still returning the
ready-to-use hex keypair:

```ts
const derive = createKeyPairDeriver(seed, 'P2PK');
for (let counter = 0; counter < gapLimit; counter++) {
  if (derive(counter).pubkey === targetPubkey) break; // matched
}
```

## Scope

Crypto primitive only. Counter allocation and the quote/proof-to-counter
mapping stay with the consumer; they need that mapping regardless, so a
wallet-owned counter would add coupling without removing consumer state.
No NUT-20 module changes needed: `createLockedMintQuote`/locked mint
already accept the pubkey/privkey directly.
Comment thread 13.md Outdated
Comment thread 13.md
- {counter}: Incrementing counter encoded as an unsigned 64-bit integer in big-endian format.

This will allow wallets to swap proof that are still locked to a public key during a restore process.

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
In line with BIP-32, if the resulting private key is out of range (`> N`), it should be discarded.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

Status: Backlog

Development

Successfully merging this pull request may close these issues.

6 participants