feat: put grant-ledger recipient keys under owner authority - #1344
Draft
FSM1 wants to merge 1 commit into
Draft
Conversation
|
Important Review skippedDraft detected. Please check the settings in the CodeRabbit UI or the ⚙️ Run configurationConfiguration used: Path: .coderabbit.yaml Review profile: CHILL Plan: Pro Plus Run ID: You can disable this status message by setting the Use the checkbox below for a quick retry:
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
FSM1
force-pushed
the
fix/recipient-key-binding-and-boundaries
branch
from
August 20, 2026 11:09
fcac687 to
e76d1ea
Compare
This was referenced Aug 20, 2026
The write-body grant ledger is authored by any committed write-grantee, and
until now no owner signature covered a row's recipientEncPk or
recipientIdentityPk. Every ledger row now carries an owner ECDSA signature
over the det-CBOR preimage {ipnsName, recipientEncPk, recipientIdentityPk,
tag}, so a re-sealer holding no owner secret verifies the binding the owner
alone could check before.
Two owner authorities now cover recipientEncPk, and the stronger one wins
where it is available. The committed tag equals blinded_tag(DH(owner subkey,
recipientEncPk), ipnsName), so a re-sealer holding the owner encryption subkey
PROVES the key honest by re-deriving the tag; the row's signature is then
redundant and a corrupted one costs the row nothing. The signature is the
substitute authority for the leg that holds no owner secret and can derive no
tag.
Where neither authority covers a row it is skipped rather than refused: no
grant blob for its tag, and the write-name wave drops it from the moved set. A
committed writer can already withhold any blob, so skipping grants it nothing
new, while refusing would let the grantee a rotation is cutting veto its own
revocation. A row the owner attested but whose tag its own subkey does not
re-derive is the two authorities disagreeing, and still fails closed.
recipientIdentityPk is the one recipient field the tag does not prove, and the
re-mint owner-signs whatever label it is handed, so an unattested row is
re-minted with the label dropped rather than laundered into an owner
signature.
Alongside it: entry_tag_is_bound is replaced by bound_recipient, which hands
back the X25519Public it validated, so the re-seal and the re-mint adopt each
row's key once instead of twice; and both boundaries gain coverage for a
planted cofactor twin and a non-canonical recipient key.
Closes #1143
Closes #1294
Closes #1295
FSM1
force-pushed
the
fix/recipient-key-binding-and-boundaries
branch
from
August 20, 2026 11:40
e76d1ea to
16bd081
Compare
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.
What this does
recipientEncPkandrecipientIdentityPklive in the write-body grant ledger, which any committed write-grantee authors. The owner-signed grant-set commitment covers(tag, permission, pseudonymPk)only, so until now a co-writer could swap a victim's encryption key under the victim's own committed tag and nothing but the owner's private ECDH could tell.Every ledger row now carries an owner ECDSA signature over the det-CBOR preimage
{ipnsName, recipientEncPk, recipientIdentityPk, tag}. Any re-sealer verifies it with no owner secret at all, which closes the write-grantee re-seal residual and makes the swap self-detaching: an altered row verifies against no owner key.The preimage is deliberately not in
GrantSetCommitment— that structure is plaintext in the envelope and encryption subkeys are public via contact codes, so putting recipient keys there would be a confirmation oracle for "is Bob a grantee of this scope" and a direct break of blinded-tag unlinkability.Two authorities, stronger one wins
The committed tag equals
blinded_tag(DH(owner subkey, recipientEncPk), ipnsName), so a re-sealer holding the owner encryption subkey proves the key honest by re-deriving the tag — a verdict no write-grantee can influence, since the only keys reproducing a committed tag are the cofactor class core refuses to adopt. There the row'sownerSigis redundant and a corrupted one costs the row nothing. The signature is the substitute authority for the leg holding no owner secret, which can derive no tag at all.Getting this ordering wrong was the one HIGH both self-review gates found independently, and it is fixed: an attestation-first gate would have let any co-writer flip one bit of a victim's
ownerSig— no key material needed — and silently, permanently revoke them, laundered into the owner's own re-signed commitment on the next wave.recipientIdentityPkis the one recipient field the tag does not prove, and the re-mint owner-signs whatever label it is handed. A row the tag proves but the signature does not is therefore re-minted with the label dropped (UNATTESTED_IDENTITY_PK) rather than laundered into an owner signature. A corrupted signature and an edited label are the same evidence, so the owner vouches for neither.The policy decision it forced
#1143asked for a call on what an owner-held re-seal does with a row that fails the binding: abort (the behaviour before this PR), or publish no blob for that tag.Decided: skip the row. The signature is per row rather than per set precisely so this is expressible.
The fail-closed half stays: a row the owner attested but whose tag its own subkey does not re-derive is the two authorities disagreeing, and fails the whole re-seal closed before anything is sealed — as does a key core will not adopt at all.
Residual, recorded rather than hidden: the grantee leg cannot prove a row whose signature a writer corrupted, so it skips it until the owner's next write-name wave repairs the attestation. And neither leg reports the skip to the host — #1351 carries the
AbuseEventsurface #1143's Fix section also named, which needs a return-type change across 44reseal_scope_rootcall sites.Wire and KAT
GrantLedgerEntrygains a requiredownerSig(64-byte compact ECDSA). Missing ismissing-field, wrong length isinvalid-field-length— same verdicts as its sibling fixed-width fields. No encode-side guard is owed: a fixed-width array makes both rejects unrepresentable, the same argumentexpiresAt'sNonZeroU64already carries.encode_recipient_binding/sign_recipient_binding/verify_recipient_bindingincrates/core/src/seal/write_body.rs, mirroring theencode/sign/verify_grant_settrio. Both failure modes returnTrustViolation::IdentitySignatureInvalid— no new variant, and its doc now names this third preimage.writeBodyReject9 to 11 (owner-sig-wrong-length,missing-owner-sig, both reusing listed checks) and a newrecipientBindingAcceptfamily pinning the frozen preimage encoding, mirroringgrantSetAcceptend to end.Also in this PR
entry_tag_is_boundhands back the key it validated (bound_recipient). Adoption is an Edwards lift plus a scalar multiplication, and both the re-seal and the re-mint were adopting the same bytes twice per ledger row. Now once.entry_tag_is_boundsurvives as a thin verdict wrapper.Three existing tests changed verdict rather than being deleted, because the behaviour they pinned is what this PR changes:
a_recipient_key_swapped_under_a_committed_tag_fails_closed_release_activea_swapped_recipient_key_costs_its_own_row_and_nothing_else— both legs, one blob shorta_reseal_without_the_owner_key_re_wraps_the_committed_set_as_it_standsa_ledger_row_whose_identity_key_is_not_a_curve_point_does_not_stop_the_wavea_recipient_field_the_owner_never_attested_costs_its_own_row_and_nothing_elseReview gates
/security-reviewand/crypto-privacy-reviewboth ran on this diff (the crypto gate is required — this touchescrates/coreprimitives and trust-boundary reads). Findings folded back in:ownerSigbit silently and permanently revokes a co-grantee, owner-signedrecipientIdentityPkinto a fresh owner signaturean_unattested_recipient_identity_pk_is_never_laundered_release_activeownerSigis a transferable, third-party-verifiable proof of grant membership; deniability within the writer set is goneCONTEXT.md"Grant ledger" and on the field itselfa_swapped_recipient_key_in_the_parent_ledger…asserted liveness only, and its own comment's property went uncheckedentry_tag_is_boundleft with zero production callersbound_recipient(..).is_none()classify, which has no production caller[0x11; 32]collided with arecipientEncPkfillerBoth gates cleared cross-protocol signature confusion, unlinkability at the envelope, zeroization across the new
continue, bounds/ordering, determinism seams, and KAT anti-vacuity.Adjacency
#1334putsascentPublicinto the structure-signature preimage. It is a sibling wire change in the same crate and is not in this PR; nothing here touchesAscentAuthority::CarriedPublicor the ascent preimage.Gates
cargo fmt --all --check,cargo clippy --workspace --all-targets -D warnings,cargo test --workspace,cargo check -p cipherbox-core --target wasm32-unknown-unknown, both KAT generators re-run with no drift,pnpm lint:tracker-refs.Mutation-checked: removing the attestation guard reddens
a_swapped_recipient_key_costs_its_own_row_and_nothing_else,a_recipient_field_the_owner_never_attested_costs_its_own_row_and_nothing_elseanda_swapped_recipient_key_in_the_parent_ledger_costs_only_its_own_row; dropping the tag-binding half ofbound_recipientreddensan_attested_parent_row_the_owner_cannot_re_derive_fails_closed_release_active.Closes #1143
Closes #1294
Closes #1295