Skip to content

feat(clearsign): provider-key derivation for the BIP-85 ceremony - #421

Open
BitHighlander wants to merge 5 commits into
developfrom
feat/clearsign-provider-key-ceremony
Open

feat(clearsign): provider-key derivation for the BIP-85 ceremony#421
BitHighlander wants to merge 5 commits into
developfrom
feat/clearsign-provider-key-ceremony

Conversation

@BitHighlander

@BitHighlander BitHighlander commented Aug 15, 2026

Copy link
Copy Markdown
Collaborator

Derive a clear-sign provider key from a BIP-85 child mnemonic, run that ceremony from the Studio, and prove the resulting key works on real hardware.

A provider is a third-party identity that supplies decode context. It is not KeepKey attestation and never claims to be: the device shows the provider's own alias and fingerprint, the context is purely additive, and a runtime signer can never suppress the raw-data review. Nothing here needs a firmware change — it all works on stock 7.15.0-rc29.

Three parts

Derivation core (src/shared/clearsign-provider-key.ts). The fingerprint is asserted against the firmware's actual algorithm rather than assumed — sha256_Raw(pubkey, 33) then data2hex(digest, 4) over the compressed key. An operator compares that fingerprint on the OLED against a file they are about to hand a live service; if the two disagree they cannot tell which key they trusted. An invalid mnemonic throws rather than deriving from garbage, because a typo otherwise yields a valid-looking key whose fingerprint never matches any device — indistinguishable from a bug.

The Studio tab. The ceremony as a task, not a set of primitives: alias, word count, index → device displays the child seed on its own screen → operator types the words back → key derived, fingerprint shown for comparison. Derivation runs in bun, so the renderer only ever sees the public half; the private key goes straight to a file, chmod 0600 explicitly because Bun.write creates 0644 and this is a live signing key in plaintext (measured, not assumed). The mnemonic is cleared from component state on success.

Device tests (keepkey-sdk). provider-live-sign-flow.js drives the live path against a provider server and checks the three refusals before the happy path — uncurated contract, trailing calldata, missing fee model must all be declined. Those matter more than the success case: a matched blob replaces the device's raw-data screen, so a signer that attests what it cannot decode is worse than no signer. provider-key-schema-flow.js covers the static v2 path. Both skip cleanly with a reason when no server is present, so run-all.js stays green without one.

What BIP-85 does and does not buy

No custody. The derived key is fully exposed once it is in a live service, exactly like any hot key. What it buys is a ceremony that is deterministic, repeatable and documented — the key can be re-derived from device + index instead of existing as a file of unexplained origin. That is what makes an unsigned provider auditable rather than merely unverified.

Path is m — the BIP-85 child mnemonic is the key material, so the provider key is its master key. Not a coin-type path, which would imply an Ethereum account that does not exist.

Verified

  • make test-unit — 501 pass, 0 fail
  • tsc --noEmit — 627, unchanged from baseline
  • Hardware, 7.15.0-rc29: live provider path 9/9, static schema path 8/8. Signer loaded after fingerprint comparison on the OLED, then a live-attested USDC transfer clear-signed under the provider identity.

For review

clearsignDeriveProviderKey is deliberately not gated on AdvancedMode — it touches no device and exposes nothing. Every sibling clearsign RPC does gate. Flagging it rather than quietly matching the neighbours; happy to add the gate if reviewers prefer consistency over the reasoning.

The key file is plaintext JSON on the host. Encryption is a follow-up.

"[swap] SIGN FAILED: Invalid Solana instruction schema" on a relay Solana swap,
with no user interaction — the device rejected before any confirm screen.

## The perverse part

Having clear-sign material made the flow WORSE than not having it:

  no schema found  -> needsOpaqueSolanaFallback -> consent panel -> blind sign -> works
  schema found     -> fallback skipped -> schema attached -> HARD REJECT

swap.ts attached the schema whenever findSolanaSchema returned one, without
checking the device could verify it. Firmware then fails the entire request
(fsm_msg_solana.h:803) and deliberately does not fall back — the comment above
it is explicit: "Present-but-invalid schema material fails the request; it never
silently degrades to blind signing." That fail-closed stance is correct; sending
material we know is unusable is not.

Verification runs signed_metadata_verify_attestation, which resolves the key via
metadata_pubkey_for -> RAM-loaded signers only, and rejects a loaded signer
unless AdvancedMode is on. Both die on power cycle. So on a stock or
just-rebooted device the outcome is not "might fail" but GUARANTEED failure —
worded as though the user's transaction were malformed.

## Fix

Withhold the schema when AdvancedMode is known-off, and mirror that in the
opaque-fallback predicate so the swap takes the consent path it would have taken
had no schema existed. The user gets the opt-in they should always get instead
of a reject, consistent with #417/#419.

AdvancedMode is a NECESSARY condition for verification, so this removes the
whole guaranteed-failure class. It is not sufficient: AdvancedMode on with no
signer armed still fails, because Vault does not track which signers are loaded
(clearsignLoadSessionSigner sends the message and records an event, but keeps no
state). That residual now gets an actionable message naming the real cause
instead of "Invalid Solana instruction schema", which blames the transaction.

Closing the residual properly means tracking loaded key ids and invalidating
them on reboot/disconnect. Deliberately not done here: stale tracking state
would reintroduce exactly this bug, and the AdvancedMode check already covers
the reboot case since the policy resets too.

make test-unit   488 pass, 0 fail across 33 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged, 2 below baseline 629
Follow-up: the AdvancedMode-based withholding in the previous commit was not
enough, and it inverted.

Observed: AdvancedMode off correctly withheld the schema and the device asked to
enable it ("Enable AdvancedMode to blind-sign" -> opt-in panel, as designed).
The user enabled it. The next attempt re-attached the schema, the device could
not verify it, and they hit "Invalid Solana instruction schema". Fixing the
first refusal directly caused the second.

That is the tell that predicting device capability is unwinnable here.
Verification needs a signer loaded in RAM; Vault does not track which are
loaded; and both that and AdvancedMode die on power cycle. Any predicate built
on what we can see is wrong some of the time, and the AdvancedMode one is wrong
in the most confusing possible direction.

So react instead of predict. Firmware validates schema material BEFORE drawing
any confirm screen, so the refusal costs the user nothing and shows them
nothing. On refusal, ask for blind-sign consent.

Deliberately NOT a silent re-sign without the schema. Dropping it means the
transaction gets blind-signed, and the opaque-consent panel exists to show what
it actually moves (host-side outflow simulation) before that happens. Skipping
it would trade a gate for a silent downgrade — worse than the bug.

Schema attachment is now also suppressed once allowSolanaBlindSigning is set,
or the consent retry would re-attach, be refused again, and loop.

Resulting flow, no dead ends:

  AdvancedMode off            -> schema withheld -> consent -> blind sign
  AdvancedMode on + signer    -> schema attached -> verified -> clear sign
  AdvancedMode on, no signer  -> refused -> consent -> retry w/o schema -> signs

make test-unit   488 pass, 0 fail across 33 files
tsc --noEmit     627, unchanged, 2 below baseline 629
First piece of the clear-sign provider work: derive a provider signing key from
a BIP-85 child mnemonic, deterministically, with a fingerprint that matches what
the device displays.

Deliberately the derivation core and not UI. The Studio tab still needs design
decisions (it is being re-authored), but this part cannot be wrong: an operator
compares a fingerprint on the OLED against a file they are about to hand a live
service, and if those disagree they cannot tell which key they trusted. Firmware
calls that confirm "the thing the whole trust model hangs on".

Parity is asserted against the firmware algorithm rather than assumed —
signed_metadata_pubkey_fingerprint is sha256_Raw(pubkey, 33) then
data2hex(digest, 4), i.e. first 4 bytes as 8 hex chars over the COMPRESSED key.

What BIP-85 does and does not buy is stated in the module, because it is easy to
overclaim and I did exactly that earlier: it gives NO custody. The derived
private key is loaded into a live service and is fully exposed there like any
hot key. What it gives is a ceremony that is deterministic, repeatable and
documented — the key can be re-derived from device + index instead of existing
as a file of unexplained origin. That is what makes an unsigned provider
auditable rather than merely unverified.

Notes on the choices:

- Derivation path is `m` — the BIP-85 child mnemonic IS the key material, so
  the provider key is its master key. Not a coin-type path: this key signs
  clear-sign descriptors, not transactions, and borrowing m/44'/60'/… would
  imply an Ethereum account that does not exist.
- An invalid mnemonic throws instead of deriving from garbage. A typo would
  otherwise produce a valid-looking key whose fingerprint never matches any
  device, and the operator could not distinguish that from a bug.
- The key file states plainly what the key can and cannot do: it can MISLABEL
  a transaction under this provider identity, it can never conceal one, because
  a runtime signer is annotation-only and cannot remove the raw review.

Key file is plaintext JSON on the host for now; encryption is a follow-up.

make test-unit   501 pass, 0 fail across 34 files, +35 btc-backend, +10 cosmos
tsc --noEmit     627, unchanged, 2 below baseline 629
The previous commit landed the derivation core deliberately without UI,
because the Studio tab still needed design decisions. This is that tab.

The ceremony, as a task rather than a set of primitives: pick an alias, word
count and index; the device displays the child seed on its own screen; the
operator types the words back; the provider key is derived and written out,
and the fingerprint is shown so it can be compared against what the device
will display when the signer is loaded.

Derivation runs in bun, not the webview. The renderer only ever sees the
public half — the private key goes straight to a file and never crosses the
RPC boundary. That file is chmod 0600 explicitly: Bun.write creates 0644,
which for a live signing key in plaintext is not acceptable (measured, not
assumed). The mnemonic is cleared from component state on success.

The device fingerprint is stamped into the key file for provenance, so the
ceremony can be repeated from device + index, and skipped for passphrase
wallets per the existing privacy rule.

clearsignDeriveProviderKey is deliberately NOT gated on AdvancedMode: it
touches no device and exposes nothing. Every sibling clearsign RPC does gate,
so this is a considered inconsistency rather than an oversight — flagging it
for review rather than quietly matching the neighbours.

make test-unit  501 pass, 0 fail
tsc --noEmit    627, unchanged
Two device suites for the clear-sign provider tier. Both skip cleanly with a
reason when their server is absent, so run-all.js stays green without one.

provider-live-sign-flow.js — the live path. Asks a provider server to attest
the exact transaction about to be signed, loads the returned identity, and
clear-signs. It checks the three refusals before the happy path, because
those matter more: an uncurated contract, calldata with trailing bytes, and a
tx with no fee model must all be DECLINED. A matched blob replaces the
device's raw-data screen, so a signer that attests what it cannot decode is
worse than no signer at all.

provider-key-schema-flow.js — the static v2 path, consuming only what a
server publishes rather than building a schema in-process, so a pass means
those bytes reached the device.

Both print the fingerprint the device must display and say plainly to reject
anything else. That comparison cannot be automated — the device never reports
the fingerprint back over the wire, which is exactly why a human confirms it.

Verified on 7.15.0-rc29: live path 9/9, static path 8/8.
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.

1 participant