Conversation
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.
E2E Rust Apps FailedOne 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>
chefsale
approved these changes
Sep 15, 2026
This branch has not been deployed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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
feat(account)RootKeyHandoff.new_root_sign_pkis a taggedRootPublicKey(Ed25519variant 0,P256variant 1, SEC1 compressed). Root verification dispatches on the tag; P-256 usesverify_prehashover the 32-bytedomain_hashdigest. The genesis stays Ed25519, so noAccountIdchanges. Signed-op schema versions bump (group 11 → 12, namespace 8 → 9), since handoffs ride insideAccountKeysRotated,AccountDeviceLinkedand every join credential; theAccountKeysRotatedgolden bytes gain the tag byte.fix(governance-store)GroupAccountKeyValue.root_pkbecomesRootPublicKey.apply_rotationno 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.mdgainsRootPublicKey/RootKeyErrorand 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 assertsverify_device_certaccepts the credential, then that the group admits it.Before:
After: passes. The 26 existing Ed25519 rotation tests pass unchanged.
Real nodes, real hardware root. Two fresh
merodnodes built from this branch:merod init, installs an app, creates a namespace, invites.merod init --no-account-root --account-root <genesis>— an ordinary Ed25519 device key.DeviceCert(Touch ID) as anAccountProof { genesis, chain: [Ed25519 → P-256], statement }.merod account import-certverifies and stores it; B joins withmeroctl namespace join.GET /admin-api/groups/<namespace>/member-devices:BindingRejected,CredentialInvalidor R2 refusal in either node's log.Seal.
a_member_whose_root_is_p256_is_refused_rather_than_sealed_tobeside the four existingseal_to_accounttests, which pass unchanged.CI, locally.
./scripts/check-like-ci.py: 14/14 passed (fmt, clippy-D warningsincl. mock-attestation, fullcargo test, store all-features, scenario coverage, deny, machete) on the rebased branch.Risks and limits
RootKeyHandoffgrows one byte (the tag) and its signing preimage hashes the tagged key; the byte-pinnedHANDOFFfixture 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).GroupAccountKeyValueno longer decodes rows written before this change. Assumes fresh nodes; there is no migration.Not in this PR
DeviceCertnaming a hardware or passkey device key).merod account sign-certstill signs at epoch 0 with an empty chain.