Skip to content

feat: put grant-ledger recipient keys under owner authority - #1344

Draft
FSM1 wants to merge 1 commit into
mainfrom
fix/recipient-key-binding-and-boundaries
Draft

feat: put grant-ledger recipient keys under owner authority#1344
FSM1 wants to merge 1 commit into
mainfrom
fix/recipient-key-binding-and-boundaries

Conversation

@FSM1

@FSM1 FSM1 commented Aug 20, 2026

Copy link
Copy Markdown
Owner

What this does

recipientEncPk and recipientIdentityPk live 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's ownerSig is 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.

recipientIdentityPk is 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

#1143 asked 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.

  • A row neither authority covers gets no grant blob; its tag stays in the committed set and the ledger — the same state a stripped blob leaves, which the section codec admits.
  • A write-name wave drops it from the moved set — there is no honest key to re-mint it at the new name under.
  • Aborting instead would hand the write-grantee a rotation is cutting a veto over its own revocation, and the write-name wave is the write revocation.
  • Skipping grants an attacker nothing new: a committed writer authoring a section can already omit any blob it likes, so blob withholding is already within its reach. What it can no longer do is redirect the next seed to a key it holds.

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 AbuseEvent surface #1143's Fix section also named, which needs a return-type change across 44 reseal_scope_root call sites.

Wire and KAT

  • GrantLedgerEntry gains a required ownerSig (64-byte compact ECDSA). Missing is missing-field, wrong length is invalid-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 argument expiresAt's NonZeroU64 already carries.
  • New encode_recipient_binding / sign_recipient_binding / verify_recipient_binding in crates/core/src/seal/write_body.rs, mirroring the encode/sign/verify_grant_set trio. Both failure modes return TrustViolation::IdentitySignatureInvalid — no new variant, and its doc now names this third preimage.
  • KATs regenerated from the committed generator: writeBodyReject 9 to 11 (owner-sig-wrong-length, missing-owner-sig, both reusing listed checks) and a new recipientBindingAccept family pinning the frozen preimage encoding, mirroring grantSetAccept end to end.

Also in this PR

  • entry_tag_is_bound hands 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_bound survives as a thin verdict wrapper.
  • Boundary coverage for an unadoptable recipient key at the re-seal and the re-mint, both spellings (cofactor twin and the key with bit 255 set). Reachable only under the owner's own signature now, since a planted row detaches its attestation and is skipped first, so the tests sign the twin — the owner's two authorities over one row disagreeing.

Three existing tests changed verdict rather than being deleted, because the behaviour they pinned is what this PR changes:

was now
a_recipient_key_swapped_under_a_committed_tag_fails_closed_release_active a_swapped_recipient_key_costs_its_own_row_and_nothing_else — both legs, one blob short
a_reseal_without_the_owner_key_re_wraps_the_committed_set_as_it_stands folded into the above; the residual it documented is closed
a_ledger_row_whose_identity_key_is_not_a_curve_point_does_not_stop_the_wave folded into a_recipient_field_the_owner_never_attested_costs_its_own_row_and_nothing_else

Review gates

/security-review and /crypto-privacy-review both ran on this diff (the crypto gate is required — this touches crates/core primitives and trust-boundary reads). Findings folded back in:

finding disposition
HIGH (both gates, independently) — attestation-first inverts the strength ordering on every owner-held leg; one flipped ownerSig bit silently and permanently revokes a co-grantee, owner-signed fixed: ECDH proof takes precedence, attestation is the fallback. Three release-active tests, mutation-checked
HIGH follow-on (crypto gate) — the repair path would launder an attacker-chosen recipientIdentityPk into a fresh owner signature fixed: the label is dropped on that path, an_unattested_recipient_identity_pk_is_never_laundered_release_active
MEDIUM (crypto gate) — ownerSig is a transferable, third-party-verifiable proof of grant membership; deniability within the writer set is gone accepted and recorded. No fix exists — every co-writer must verify, which rules out a designated-verifier construction, and contact codes already bridge an X25519-only preimage to an identity. Written down in CONTEXT.md "Grant ledger" and on the field itself
LOW (security gate) — a_swapped_recipient_key_in_the_parent_ledger… asserted liveness only, and its own comment's property went unchecked fixed: it now asserts no blob opens under the attacker's key, with a bystander row so the assertion is not vacuous
NIT — broken intra-doc link to a renamed function fixed
NITentry_tag_is_bound left with zero production callers deleted; its two tests use bound_recipient(..).is_none()
NIT — a comment appealed to classify, which has no production caller reworded to state the condition instead
NIT — the KAT owner scalar [0x11; 32] collided with a recipientEncPk filler distinct filler; vectors regenerated
NIT (crypto gate) — no mechanical separation KAT over identity-signed preimage key sets out of scope, filed as #1352. Separation here is structural (disjoint required key sets, verified pairwise in the review) but not machine-checked

Both gates cleared cross-protocol signature confusion, unlinkability at the envelope, zeroization across the new continue, bounds/ordering, determinism seams, and KAT anti-vacuity.

Adjacency

#1334 puts ascentPublic into the structure-signature preimage. It is a sibling wire change in the same crate and is not in this PR; nothing here touches AscentAuthority::CarriedPublic or 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_else and a_swapped_recipient_key_in_the_parent_ledger_costs_only_its_own_row; dropping the tag-binding half of bound_recipient reddens an_attested_parent_row_the_owner_cannot_re_derive_fails_closed_release_active.

Closes #1143
Closes #1294
Closes #1295

@coderabbitai

coderabbitai Bot commented Aug 20, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 76f2aec5-58eb-43c7-8624-92fde58710d2

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

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
FSM1 force-pushed the fix/recipient-key-binding-and-boundaries branch from e76d1ea to 16bd081 Compare August 20, 2026 11:40
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant