Skip to content

feat(account): let an account root live in hardware (P-256), end to end - #3922

Draft
xilosada wants to merge 5 commits into
masterfrom
poc/hardware-root-p256
Draft

xilosada wants to merge 5 commits into
masterfrom
poc/hardware-root-p256

Conversation

@xilosada

Copy link
Copy Markdown
Member

Summary

An account is born Ed25519 from its recovery phrase and can now hand its root off to a P-256 key: the only type a secure element (Apple Secure Enclave, Android StrongBox/TEE, TPM) or a YubiKey PIV slot holds. The root only certifies devices; device keys stay Ed25519, so nothing about how ops verify changes.

Before this, a device whose certificate was signed by such a root was refused at join on every peer, even though the credential verifies.

What changed

Commit Change
feat(account) RootKeyHandoff.new_root_sign_pk is a tagged RootPublicKey (Ed25519 variant 0, P256 variant 1, SEC1 compressed). Root verification dispatches on the tag; P-256 uses verify_prehash over the 32-byte domain_hash digest. The genesis stays Ed25519, so no AccountId changes. Signed-op schema versions bump (group 11 → 12, namespace 8 → 9), since handoffs ride inside AccountKeysRotated, AccountDeviceLinked and every join credential; the AccountKeysRotated golden bytes gain the tag byte.
fix(governance-store) GroupAccountKeyValue.root_pk becomes RootPublicKey. apply_rotation no longer bails on a non-Ed25519 handoff: it stores the key the handoff names and verifies the next handoff with the stored key's own algorithm.
docs(account) AGENTS.md gains RootPublicKey / RootKeyError and the invariant that only the root is tagged.
fix(server) seal_to_account (#3918) seals to the root's X25519 form, which only an Ed25519 root has. A P-256 root is now refused with a typed error answered as 422, reachable only after both membership gates.

Proof

Reproduction. crates/governance-store: a_device_certified_by_a_p256_root_is_admitted. An account born Ed25519 hands off to a P-256 root, which certifies an ordinary Ed25519 device; the test first asserts verify_device_cert accepts the credential, then that the group admits it.

Before:

test account_bindings::tests::a_device_certified_by_a_p256_root_is_admitted ... FAILED
panicked at crates/governance-store/src/account_bindings.rs:1396:14:
store: cannot persist a p256 root key: the account-key row is a fixed 32 bytes and has no schema version (design risk R2)

After: passes. The 26 existing Ed25519 rotation tests pass unchanged.

Real nodes, real hardware root. Two fresh merod nodes built from this branch:

  1. Node A: ordinary merod init, installs an app, creates a namespace, invites.
  2. Node B: merod init --no-account-root --account-root <genesis> — an ordinary Ed25519 device key.
  3. The account root, rotated into the macOS Secure Enclave, signs node B's DeviceCert (Touch ID) as an AccountProof { genesis, chain: [Ed25519 → P-256], statement }.
  4. merod account import-cert verifies and stores it; B joins with meroctl namespace join.
  5. Node A, GET /admin-api/groups/<namespace>/member-devices:
    account 363924edd577bf1cbb1dbd0cf9ce4f77e845d4cef812927d848992233095ba67
      device 75517d47eec11210a87c6cb0c4fd5aa90cf571ef94ca81b69e34c3ad1452cff9
    
    No BindingRejected, CredentialInvalid or R2 refusal in either node's log.

Seal. a_member_whose_root_is_p256_is_refused_rather_than_sealed_to beside the four existing seal_to_account tests, which pass unchanged.

CI, locally. ./scripts/check-like-ci.py: 14/14 passed (fmt, clippy -D warnings incl. mock-attestation, full cargo test, store all-features, scenario coverage, deny, machete) on the rebased branch.

Risks and limits

  • Flag day. A RootKeyHandoff grows one byte (the tag) and its signing preimage hashes the tagged key; the byte-pinned HANDOFF fixture is regenerated. Both signed-op schema versions are bumped, so every op id changes and old and new nodes reject each other's ops rather than mis-decoding them: every node re-bootstraps. merobox E2E cannot catch mixed versions (same build everywhere).
  • Store-row encoding changes, with no schema version. GroupAccountKeyValue no longer decodes rows written before this change. Assumes fresh nodes; there is no migration.
  • Handoff-candidate map (projection) is untouched. Keying it on the signature is what stops a forged handoff displacing the real one; with a randomized signer (ECDSA), re-signing the same handoff yields a second entry. Plain re-gossip carries identical bytes and is unaffected.
  • Sealing to a P-256 root is refused, not supported. That needs P-256 ECIES.

Not in this PR

  • P-256 device keys (a DeviceCert naming a hardware or passkey device key).
  • Encrypting the node store with a hardware-sealed key.
  • Tooling to sign a hardware-root credential: the PoC used a standalone tool outside the repo; merod account sign-cert still signs at epoch 0 with an empty chain.

Root key handoffs now carry a RootPublicKey (Ed25519 = variant 0, P-256 =
variant 1, SEC1 compressed), so an account born Ed25519 from a recovery
phrase can rotate onto a hardware-held P-256 key. The genesis is unchanged,
so every existing AccountId is unchanged.

P-256 verifies with verify_prehash over the 32-byte domain_hash digest, the
exact shape Secure Enclave and PIV tokens sign.

Wire: a RootKeyHandoff grows one byte (the tag) and its signing preimage
hashes the tagged key, so the HANDOFF fixture is regenerated and the
AccountKeysRotated golden bytes gain the tag byte. Handoffs ride inside
AccountKeysRotated, AccountDeviceLinked and every join credential, so both
signed-op schema versions bump (group 11 -> 12, namespace 8 -> 9): an old
peer rejects these ops rather than mis-decoding the key.

The handoff-candidate map in the projection is untouched: keyed on the
signature, re-signing the same handoff with randomized ECDSA yields a second
candidate. Plain re-gossip carries identical bytes and is unaffected.
…root can link a device

A device join carries a self-certifying credential: the genesis, the root-key
handoff chain and a DeviceCert. apply_link absorbs that chain into the group
before checking the certificate, and apply_rotation refused any non-Ed25519
key, because GroupAccountKeyValue.root_pk was a fixed [u8; 32]. So a device
certified by a root rotated onto a secure element or a PIV slot (P-256) was
refused with an error on every peer, even though verify_device_cert accepts
the credential.

GroupAccountKeyValue.root_pk is now a RootPublicKey. The tag keeps Ed25519 and
P-256 roots in the same row, apply_rotation stores the key the handoff names
and verifies the next handoff with the stored key's own algorithm, and
account_key returns the tagged key. The device key itself stays Ed25519.

The row's encoding changes and there is no store-row schema version, so this
assumes fresh nodes rather than an upgraded database.

Test: a_device_certified_by_a_p256_root_is_admitted failed with the R2
refusal and now passes; the Ed25519 rotation tests are unchanged.
The account crate's API table predated the tagged root key. Adds rows for
RootPublicKey, its verify and to_wire, RootKeyError and root_pk.rs; notes that
RootKeyHandoff::sign now takes a RootPublicKey; and corrects the chain-cap note,
which said every handoff costs an Ed25519 verification.

Also records the invariant the hardware-root design rests on: only the root is
algorithm-tagged. A device key is always Ed25519, and a genesis stays Ed25519
because it is the preimage of the AccountId.
seal_to_account (#3918) seals to an account's current root key through its
X25519 form, and read that root from account_key as a 32-byte Ed25519 key.
account_key now returns the tagged RootPublicKey, so this no longer compiled,
and a P-256 root, held in a secure element or a PIV slot, has no X25519 form
to seal to.

An Ed25519 root is sealed to exactly as before. Any other root is refused with
a typed RootNotSealable error that the handler answers as 422 naming the
algorithm, rather than a 500. It is reachable only for a target both
membership gates already admit, so it reveals nothing about which accounts
exist, and the shared 404 for unknown accounts is unchanged.

Test: a_member_whose_root_is_p256_is_refused_rather_than_sealed_to, beside the
four existing seal tests, which pass unchanged. The accounts doc gains the
limit.
@github-actions

Copy link
Copy Markdown

E2E Rust Apps Failed

One or more E2E workflows (scaffolding-e2e, xcall-example) failed.

Please check the workflow logs for more details.

frdomovic added a commit that referenced this pull request Sep 14, 2026
… match

The Rust job was failing on `cargo deny`, not on anything this branch wrote:

  error[vulnerability]: TLS 1.3 handshake messages incorrectly accepted
  across encryption level boundaries
  rustls 0.23.37 — RUSTSEC-2026-0285
  advisories FAILED, bans ok, licenses ok, sources ok

⚠️ NOT INTRODUCED HERE, AND NOT FIXABLE BY REBASING. This branch does not
touch Cargo.lock at all — it changes nine .rs files. The advisory was
published on 2026-09-14, and cargo-deny fetches the advisory DB at run time,
so the same lockfile that passed yesterday fails today. #3922 went red the
same way within the hour while older PRs stayed green; every PR opened from
now on would have hit it.

`cargo update -p rustls` alone only reaches 0.23.43 and the advisory wants
>= 0.23.45, so this pins 0.23.45 precisely, which carries aws-lc-fips-sys,
aws-lc-rs, aws-lc-sys 0.38.0 -> 0.45.0 and rustls-webpki 0.103.9 -> 0.103.15
with it.

That bump also resolves six advisories deny.toml was suppressing "pending
libp2p upgrade" — 2026-0044, 0048, 0049, 0098, 0099 and 0104 — so they are
removed.

⚠️ A STALE IGNORE IS NOT HARMLESS. cargo-deny reports it as
`advisory-not-detected`, and until someone prunes it the id sits in the file
ready to silently suppress that advisory if the crate ever returns. The four
that still match are kept.

Verified with the version CI pins rather than whatever was newest:
cargo-deny 0.19.9 gives `advisories ok, bans ok, licenses ok, sources ok` and
no not-detected warnings, and `cargo check --workspace --all-targets` is clean
across the aws-lc-sys major bump.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

This branch has not been deployed

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants