Skip to content

Q: seed derivation, and what the unhardened path costs a C firmware - #108

Closed
TheCryptoDonkey wants to merge 4 commits into
dni:mainfrom
TheCryptoDonkey:feat/seed-recoverable-notes
Closed

Q: seed derivation, and what the unhardened path costs a C firmware#108
TheCryptoDonkey wants to merge 4 commits into
dni:mainfrom
TheCryptoDonkey:feat/seed-recoverable-notes

Conversation

@TheCryptoDonkey

@TheCryptoDonkey TheCryptoDonkey commented Aug 25, 2026

Copy link
Copy Markdown
Collaborator

Read this as a question, not a feature

You said you'd bring your signer's implementation across into the reference
vault. This is not an attempt to get in front of that. It exists because
implementing the section here answers one question that is much harder to
answer by reading: what the path costs a firmware with no elliptic-curve
code in it.

Take it, bin it, or take just the argument and write your own. If you'd rather
do it yourself, say so and I'll close this.

The one thing worth deciding before either of us writes more

The section hardens 139' and i' but not d1..d4, and its cashHashingKey
is 139'/0 where this PR uses 139'/0' (that second one changes
cashHashingKey, so d1..d4, so every secret - the two schemes miss each
other's notes even once the d1..d4 question is settled). Unhardened CKDpriv
hashes serP(point(kpar)), the parent public key, so computing one needs
a secp256k1 point multiplication.

Your signer has secp256k1. This firmware has none. It does carry curve25519,
in monocypher, for the Ed25519 OTA signature check, but that is a different
curve with nothing reusable here. What it does have to hand is SHA-512 and
HMAC-SHA512, linked in with monocypher-ed25519.c for that OTA check, plus its
own SHA-256.

Hardened CKDpriv needs exactly those, plus 256-bit modular addition. That is
the whole of bn_add_mod_n in this PR, and the whole difference between 300
lines of arithmetic and porting an EC implementation into the reference vault.

Nothing is lost by hardening. Unhardened derivation buys one thing: deriving
child public keys without the private key. A note secret has no public
counterpart in this protocol, SERVICE only ever sees sha256(secret), so
the capability has nothing to act on. LUD-05 leaves those levels unhardened
because a linkingKey is a real signing keypair; the shape does not carry over
to opaque 32-byte preimages, and it costs exactly the devices most likely to
be holding these notes.

So this PR implements the hardened variant, which means it does not match
the section as written.
If you keep d1..d4 unhardened, derive.c is wrong
and I'll rewrite it or close this. That is the decision I'm asking for.

Two details that need pinning either way

Whoever writes it, and whatever you decide above:

  • The domain levels need masking to 31 bits. A hardened index has no 32nd
    bit, and d1..d4 come out of a hash, so roughly half of them overflow. This
    masks with HARDENED - 1. It costs 4 bits out of 128 that were only ever
    separating domains.
  • The domain hashed should be the withdraw endpoint's host AND path. A
    note is the whole URL, and two mints can share a hostname, so hashing the
    host alone gives them one tree between them.

Get either wrong in two implementations and the trees differ, and a restore
finds nothing while reporting no error at all.

Verification

Native suite 1026/1026, ASan + UBSan clean, CC=gcc-16 per the Makefile's own
warning about Apple clang. Firmware builds for both boards. Nothing calls it
and no behaviour changes.

The BIP-32 vectors are the published ones from the BIP, including test vector
3 whose master key begins with a zero byte, which is the case implementations
get wrong by dropping the left pad. The LUD-25 path vectors were generated by
an independent Python implementation of the same scheme, so a match is two
implementations agreeing rather than one repeating itself.

Seed generation moved out to #110, which is independent of all of the above.

LUD-25's Seed-recoverable note secrets section: every secret this vault
generates for a note is drawn from the hardware RNG, and a seed phrase backs
up nothing that was never derived from it. Restore onto a fresh device today
and every outstanding note is gone, silently, until someone tries to redeem
one whose preimage no longer exists anywhere.

This is the derivation core only -- nothing calls it yet, and no note
behaviour changes. It is deliberately the first piece, because it is what
decides whether the section is implementable on a device this small.

Every level of the path is hardened, which diverges from the section as
drafted (139' and i' hardened, d1..d4 not). Unhardened CKDpriv hashes the
parent PUBLIC key, so it needs a secp256k1 point multiplication, and this
firmware carries no EC code at all. Hardened CKDpriv needs HMAC-SHA512 --
already linked, monocypher does OTA signature checks with it -- plus 256-bit
modular addition, which is what bn_add_mod_n is. That is the whole difference
between this file and porting an EC implementation into the reference vault.

Nothing is lost by it. Unhardened derivation buys child PUBLIC keys without
the private key, and a note secret has no public counterpart here: SERVICE
only ever sees sha256(secret). LUD-05 leaves those levels unhardened because
a linkingKey is a real signing keypair; the shape does not carry over.

Two details the spec still has to pin, or two wallets will derive two trees
for one mint and a restore will quietly find nothing: the domain levels are
masked to 31 bits (a hardened index has no 32nd), and the domain hashed is
the withdraw endpoint's host AND path, since a note is the whole URL.

Vectors are BIP-32's own published ones (including test vector 3, whose
master key begins with a zero byte), plus LUD-25 path vectors generated by an
independent Python implementation of the same scheme.
@TheCryptoDonkey
TheCryptoDonkey force-pushed the feat/seed-recoverable-notes branch from 4870adc to 5a11e29 Compare August 25, 2026 09:03
@TheCryptoDonkey TheCryptoDonkey changed the title feat: seed-recoverable note secrets, hardened all the way down Q: seed derivation, and what the unhardened path costs a C firmware Aug 25, 2026
@TheCryptoDonkey
TheCryptoDonkey marked this pull request as draft August 25, 2026 09:04
dni pushed a commit that referenced this pull request Aug 25, 2026
## Why

Independent of the seed-derivation question in #108, and deliberately
separate
from it: this changes nothing about how note secrets are derived, only
how the
one secret they all descend from is generated.

The note seed is the single value every note depends on for the life of
the
device, and the TRNG behind it comes with a caveat this repo already
documents. `README.md`'s security posture says Espressif's full-entropy
guarantee for `esp_fill_random()` is conditional on Wi-Fi or BT having
been
active, `main.c` starts BLE before the first secret for exactly that
reason,
and the startup self-test catches a catastrophically stuck source rather
than
a merely poor one. That is a bet on firmware ordering staying correct
forever,
and it is a bigger bet for a seed than for one note.

## What

The seed is drawn from both ends, and neither can choose it:

1. `entropy_begin` — the vault draws 32 bytes and publishes only their
sha256.
   It is now committed.
2. the host — the wallet gathers real human randomness and sends it
over.
3. `entropy_finish` — the vault mixes the two, and reveals its own
contribution so the wallet can check it against the commitment from step
1.

The commitment is the point of the ordering. Without it, a vault with a
weak
or backdoored TRNG could wait to see the host's bytes and grind its own
until
the seed landed somewhere it liked. With it, the vault is bound before
the
host speaks, and the host moves second against a value already fixed.

Because the mix is a hash, neither contribution can cancel the other: an
all-zero host leaves the seed exactly as good as the TRNG alone, a dead
TRNG
leaves it as good as the host's. It takes both being bad to get a bad
seed.

A rejected contribution closes the session rather than leaving the
commitment
live, or the same grinding just points the other way — a host retrying
against
a pinned device value samples seeds until one suits it.

## Scope

Nothing calls it yet and no behaviour changes; `new_note` still uses the
RNG
directly. It is offered as the piece that would sit under a seed,
whichever
derivation the spec settles on.

Portable, RNG injected, so the adversarial properties are what the tests
assert rather than something argued in a comment: a vault cannot swap
its
contribution, a degenerate host cannot weaken the seed, a rejected
contribution cannot be retried, and an all-zero draw refuses to start.

## Verification

- native suite 992/992, ASan + UBSan 992/992, `CC=gcc-16` per the
Makefile's
  own warning about Apple clang
- firmware builds for `t-display`
- rebased onto current `main`

Co-authored-by: TheCryptoDonkey <TheCryptoDonkey@users.noreply.github.com>
dni and others added 2 commits August 25, 2026 19:35
…y lacks

The header claimed the module diverges from the draft in one place. It
diverges in two: the draft's cashHashingKey is 139'/0 and this uses 139'/0'.
That one changes cashHashingKey, so d1..d4, so every secret - a wallet
following the draft finds none of this firmware's notes even if the d1..d4
hardening question is settled tomorrow. Worth declaring where the divergence
is declared.

It also said the firmware carries no EC code at all. It carries curve25519,
in monocypher, for Ed25519 OTA signature checks; what it has none of is
secp256k1, which is the claim the argument actually rests on. And SHA-512 and
HMAC-SHA512 are not needed BY the OTA check, they are linked in with
monocypher-ed25519.c for it.
@TheCryptoDonkey

Copy link
Copy Markdown
Collaborator Author

Three things from implementing this on the vault firmware, all questions
rather than proposals.

The restore walk. We ended up asking ?h=sha256(secret_i) rather than
?k1=secret_i. Probing by k1 puts live secrets in access logs, which
Security considerations already covers, but a restore walk also queries the
whole gap window, so it discloses the next 20 secrets the wallet has not
minted into yet and then mints into exactly those. No later rotate closes
that one. "Checking a note without exposing it" already has the safe form,
so I wondered whether the k1 form in the recovery paragraph was deliberate
or just shorthand. We shipped the by-hash walk in lnurlcash-kit 0.4.0. One
thing that fell out of it: an h a SERVICE does not index returns exactly
what an unknown k1 returns, which is right for a single lookup but leaves
a whole walk unable to tell "not supported" from "you hold nothing".

The title question, or the part not already in the description: I could
not tell how to read a d at or above 2^31. LUD-05's own test vector has
two of them (2659270754 and 4136336762), and taking its Scala at face value
they go in raw, which makes them hardened indices. @scure/bip32 refuses that
path outright, and we mask the top bit. Three implementations, three trees
for one mint, and a restore that finds nothing without erroring anywhere.
How do existing lnurl-auth wallets handle it? If there is a settled answer
we will just follow it.

Not derivation, but next door. i is local state, so one seed restored
onto two devices derives the same secret_i twice. A burned h is covered
("MUST NOT ... credit it with new value, or reissue it"), but I could not
find what SERVICE should do when a rotate names an h it already holds
outstanding. Is a rejection the intent?

None of this is urgent for us. Happy to move any of it to lnurl/luds#301 if
that is the better thread.

@TheCryptoDonkey

Copy link
Copy Markdown
Collaborator Author

You answered this by shipping, and the answer goes against this PR.

lnurl-wallet's keys.ts is explicit about the one thing I asked you to
decide:

path suffix longs are raw BIP32 child indices: whether each level ends up
hardened depends solely on its own magnitude (>= 2^31), never forced

and cashSecrets.ts hangs i' off the domain node those four levels end at.
So d1..d4 are raw, nothing is masked, nothing is forced, and that is what
"exactly as LUD-05" means by reference to LUD-05's own corpus.

Withdrawing the hardening ask, and the masking one with it. Both were
wrong, and wrong in the same way: I read an ambiguity out of the section text
without reading your implementation of it, where it isn't ambiguous at all.
derive.c on this branch is the thing that doesn't conform, not the section.
It masks the top bit and hardens all four, which derives a different tree from
every conforming wallet - a restore that finds nothing, silently, and only
once the money is gone. It should not sit here as a suggestion.

The useful half of this PR survived, though, and it turned out better than
what I was arguing for. The firmware doesn't need the curve or the
hardened variant. I've opened that separately rather than force-pushing it
over a PR whose whole body is now an argument for the wrong thing.

Closing this one. Thanks for the answer, even if it was the one that cost me
300 lines.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants