Skip to content

docs(tbtc/signer): Phase 7.2b design note — package envelopes + bound blame#4054

Merged
mswilkison merged 15 commits into
extraction/frost-signer-mirror-2026-05-26from
docs/phase-7-2b-package-envelope-design-2026-06-13
Jun 14, 2026
Merged

docs(tbtc/signer): Phase 7.2b design note — package envelopes + bound blame#4054
mswilkison merged 15 commits into
extraction/frost-signer-mirror-2026-05-26from
docs/phase-7-2b-package-envelope-design-2026-06-13

Conversation

@mswilkison

@mswilkison mswilkison commented Jun 13, 2026

Copy link
Copy Markdown
Contributor

What

A design note scoping Phase 7.2b before the implementation, matching the spec-first discipline that preceded 7.1 (and that 7.2a's blame-deferral made the case for: don't ship the feature ahead of its foundation). Now includes a companion open-questions discussion doc with the three design questions resolved post-review.

Why a note first

7.2a (#4052) ships InteractiveAggregate but fails closed without attributable blame, because the engine can't yet bind the aggregate's inputs to what each member signed — so a coordinator aggregating against a different package/root could frame honest members (the #4052 P1). The frozen spec ties trustworthy blame to signed package envelopes (§6). 7.2b builds the foundation and the feature together. It spans Go (wire/distribution/equivocation/blame adjudication) and Rust (engine candidate culprits/FFI), so it needs design before code.

Covers

  • Signed-body signing-package envelope mirroring the feat(frost/roast): protobuf signed-body envelopes for transition evidence #4040 pattern (SigningPackageBody / SignedSigningPackage): signed once, embedded verbatim, re-verified against exactly what was received. Members verify the coordinator signature, the attempt_context_hash, and taproot_merkle_root against their session root before signing.
  • Equivocation detection extending feat(frost/roast): retain signed equivocation evidence at detection points #4044: a coordinator distributing different bodies (incl. different roots) to different members is self-incriminating.
  • Blame split (corrected post-review — see below): the Rust engine does pure FROST math and returns mathematically-failing members as candidate culprits; all envelope verification + authoritative blame runs Go-side at the f+1 accuser quorum, re-checking each accused share against that member's retained envelope. culprits == full subset is treated as suspect (coordinator misconfig, not universal cheating).
  • FFI structured-culprit payload (feat(tbtc/signer): Phase 7.2a InteractiveAggregate (tweaked, self-verifying) #4052 P2): typed culprits: Option<Vec<u16>> on the error response so candidate culprits are machine-readable.
  • Completion marker + cross-language vectors.

Post-review correction

The companion discussion doc resolves the three open questions. The substantive change: an earlier draft had the engine verify envelopes — wrong, on a converging Gemini+Codex P1. The engine has no operator-key registry (can't verify operator signatures), and a coordinator-signed envelope is the wrong authentication direction for member blame. The resolution realigns to the frozen spec (§5.4/§6 — no spec amendment): engine = pure math → candidate culprits; Go = envelope verification + authoritative blame at the f+1 quorum vs the member's retained bytes. Two follow-up Codex P1s folded in: members must verify the taproot root before signing, and member-authenticated share submission is a hard prerequisite before blame is enabled.

Plan

Go/Rust split spelled out, a 5-step sub-PR sequence where authoritative blame (7.2b-4) lands only after the envelope (7.2b-2) and the engine's candidate culprits (7.2b-3), and is gated on member-authenticated share submission. Three open questions resolved (recorded in the discussion doc's Decision Log; pending owner sign-off to record in the gates-doc).

Not a frozen contract — a scoping doc to review before the 7.2b-1 implementation PR.

🤖 Generated with Claude Code

… blame

Scopes the 7.2b implementation before code, matching the spec-first
discipline that preceded 7.1. Covers the signed-body signing-package
envelope (frozen spec section 6, mirroring the #4040 pattern), the
envelope-bound attributable blame deferred from 7.2a (so blame is never
emitted without the binding that makes it unforgeable - the direct
answer to the #4052 P1), the FFI structured-culprit payload (#4052 P2),
the InteractiveAggregate completion marker, and cross-language vectors.

Lays out the Go (scaffold: wire envelope, distribution, equivocation
retention extending #4044, bridge decoder) vs Rust (mirror: Round2
binding record, bound blame, FFI payload, completion marker, vectors)
split, a 5-step sub-PR sequence where blame (7.2b-3) only lands after
its binding (7.2b-1) and the envelope (7.2b-2) exist, and three open
questions (envelope transport, equivocation-comparison location, FFI
error shape) for sign-off before implementation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Jun 13, 2026

Copy link
Copy Markdown

Important

Review skipped

Auto reviews are disabled on base/target branches other than the default branch.

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: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 187ca29a-ded5-430f-ad15-30a9fb0d7be5

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
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch docs/phase-7-2b-package-envelope-design-2026-06-13

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 and usage tips.

mswilkison and others added 9 commits June 13, 2026 12:00
…wers

Companion to the 7.2b design note. Works the three open questions
(Q1 envelope binding, Q2 equivocation-comparison location, Q3 FFI
error shape) into options tables, tradeoffs, and a recommendation each,
with a per-question "Reviewer ask" for Gemini/Claude. Carries the
coordinator-as-adversary framing (refuse-to-blame can run in the
coordinator engine; prove-equivocation must run at members) and
corrects the design note's body-hash lean on Q1 toward member-transmitted
SignedSigningPackage envelopes.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Converging P1 from both reviewers on Q1: the Rust engine must NOT verify
envelopes. It has no operator-key registry (Gemini), and a coordinator
signature is the wrong authentication direction for member blame (Codex's
framing attack: coordinator distributes P' to A, aggregates against P with
its own P-envelope, frames honest A). This realigns the design docs to the
FROZEN spec (§5.4/§6), which already specified: engine does pure FROST math
returning *candidate* culprits; all envelope verification, retention, and
authoritative blame re-check live in the Go host at the f+1 accuser quorum,
against each member's retained received bytes. No spec amendment needed.

Q2 (both concur): retention now; compare at the f+1 quorum step (Option B);
gossip deferred. Q3 (both concur): typed optional culprits: Option<Vec<u16>>
on ErrorResponse, not a generic details map.

Design-note corrections: §1/§4 (engine pure-math + Go adjudication, not
"engine verifies the coordinator signature"), §5 ([]string -> []u16, Codex
P2), §6 (Round2 record is engine-local bookkeeping), §7 (blame adjudication
in the Go column), §9 (7.2b-3 engine has no envelope handling; 7.2b-4 adds
quorum adjudication), §10 (superseded body-hash proposal withdrawn, Codex
P2), §11 (candidate-vs-authoritative split). Discussion doc adds a
post-review Decision Log; pending owner sign-off to record in the gates-doc.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…ixes

Two valid Codex P1s on the 7.2b design (no change to the Q1/Q2/Q3
decisions):

- Taproot root binding: members MUST verify taproot_merkle_root against
  their live session root before signing. The attempt context does NOT
  carry the root but Round2 signs under the session root, so without the
  check a coordinator can hand an envelope with the right package/attempt
  but a divergent root — the retained envelope then misdescribes what was
  signed and the quorum re-check misattributes blame. Added to design
  note §2 (member verify), §3 (root-equivocation is caught by the same
  machinery since the root is a body field), §11 (acceptance test).
- Member-authenticated share submission promoted from "confirm" to a hard
  prerequisite gating authoritative blame: without it a coordinator
  submits garbage as "A's share", the engine returns A as a candidate,
  and the quorum re-checks bytes A never sent. Added to §9 (7.2b-2 scope
  + 7.2b-4 gate) and §11 (acceptance), and the discussion doc's Q1
  prerequisite section.

Self-review consistency fixes: discussion-doc framing block no longer
claims job (a) can run in the coordinator engine (it's enforced at the
quorum, per the resolved Q1); Q1 title drops "the engine"; Q3 options
table Vec<String> -> Vec<u16>; design note §4/§9 drop the engine-local
Round2 package-hash record (the corrected design has no consumer);
Decision Log records both re-review P1s.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex P2 (valid concern, corrected remedy): the `culprits == entire
subset` rule is unreachable if 7.2b-3 uses the default aggregate path.
Verified against frost-core 3.0.0 / frost-secp256k1-tr 3.0.0:
- `frost::aggregate` and `aggregate_with_tweak` hard-code
  CheaterDetection::FirstCheater, so they report only the first invalid
  share (Codex right that the rule would never fire + multi-share
  failures under-reported).
- But no reimplementation is needed (Codex's proposed remedy): frost-core
  3.0.0's detect_cheater already collects every culprit under
  CheaterDetection::AllCheaters. The wrinkle is the taproot wrapper
  aggregate_with_tweak does NOT expose the mode, so the engine applies
  the tweak itself (public_key_package.tweak(merkle_root)) and calls
  frost_core::aggregate_custom(.., AllCheaters). Pinned in §4/§9 + the
  discussion-doc Decision Log; soundness still rests on the Go quorum
  re-check, not this engine heuristic.

Self-review consistency fixes (my own /review pass): purge the two
remaining references to the dropped engine-local Round2 record (design
note §6, §7 Rust column); rewrite the discussion-doc intro, which still
described a soliciting-input structure ("Reviewer ask" lines) the
RESOLVED conversion removed; de-densify design note §2 step 1 (root-check
rationale lifted below the list).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…re quorum re-check

Two valid Codex P2s on the member-side acceptance logic, plus a held
self-review finding on the quorum re-check:

- Elected-coordinator check (Codex P2): attempt_context_hash is public, so
  "valid operator signature + right attempt hash + right root" would
  accept a body from any operator. §2 now requires coordinator_id == the
  elected coordinator AND signature verified under that key. The §3
  equivocation proof also requires both divergent bodies under the same
  elected coordinator's signature.
- Retain-on-reject (Codex P2): §2 previously rejected a root-divergent
  envelope before retention, so §3's cross-member comparison lost the
  signed bytes proving root equivocation. §2 reordered: authenticate ->
  retain (genuine-coordinator evidence) -> then sign-or-refuse; a refused
  divergent envelope is retained as equivocation evidence.
- Tweak-aware, engine-delegated quorum re-check (self-review): the
  quorum's per-share crypto re-verify is FROST math and must be
  tweak-consistent (taproot is the production case). Delegated to a
  stateless engine verify-share (inputs: signing_package,
  taproot_merkle_root, verifying_share, share - never the envelope or
  operator keys), not reimplemented in Go. §4/§7 split adjudication into
  Go policy + engine crypto; §9 adds the verify-share FFI to 7.2b-3;
  §11 adds tweak-consistency + retain-on-reject + elected-coordinator
  acceptance tests.

Discussion-doc Decision Log records all three.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Last round's retain-on-reject rule (§2 step 2) means a member retains
envelopes it received and refused to sign; §3's opening "received and
signed over" contradicted that. Corrected to "received (whether or not it
signed over it)". Wording only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Two valid Codex findings (the loop had NOT converged — premature to call
it closed last pass):

- [P1] Context-bound share authentication: member-authenticated share
  submission proving only "these bytes are A's" is replayable — a
  coordinator can resubmit an old A-signed share for a different
  attempt/package, and the quorum re-checks bytes A never submitted for
  that context, framing A. The signed body MUST cover (attempt_context,
  signing-package/envelope hash, share). §4/§9/§11 + discussion-doc
  prerequisite.
- [P2] Group key in verify-share inputs: FROST share verification computes
  the challenge + binding factors from the GROUP verifying key (tweaked
  for taproot), not just the per-member verifying share. The proposed FFI
  input list omitted it. Fixed: the engine resolves the public key package
  (group key + verifying shares) from session DKG state and applies the
  tweak (consistent with 7.2a); caller passes only public inputs
  (signing_package, taproot_merkle_root, identifier, share). §4/§7/§9.

Discussion-doc Decision Log records both.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Codex P2 and my own pass converged on the same verify-share resolution
gap, two facets:
- Selector (Codex P2): the listed inputs (signing_package, root,
  identifier, share) don't identify WHICH session/DKG public key package
  the engine resolves; engine state is keyed by session_id, so multi-
  session lookups are ambiguous or could verify against the wrong group
  key. Add a session_id/wallet selector.
- Lifetime (self-review): the f+1 quorum re-check can run after the
  interactive session is TTL-swept (frozen §5), so the group key must
  resolve from durably-retained wallet DKG material that outlives the
  signing session, not the ephemeral session object.

Resolution covers both: selector + durable wallet-scoped key, with the
explicit-pass alternative noted (the public key package is public, so
passing it keeps verify-share a pure function, sound under the
f+1-independent-accuser model). §4/§7/§9 + §11 multi-session/post-sweep
test; discussion-doc Decision Log updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
…firmation

Decision Log entry 9 (roast-phase-5-security-rollout-gates.md) records sign-off of the Phase 7.2b package-envelope + Go-side bound-blame design: the Q1 engine-pure-math correction plus the eight resolutions folded across the seven adversarial review passes (Q2 retain-now/f+1 compare, Q3 typed culprits, AllCheaters, taproot root binding, context-bound member-auth share submission, elected-coordinator + retain-on-reject, verify-share FFI contract).

Also resolves the design's §9 durable-wallet-pubkey-package-retention question as already satisfied: dkg_public_key_package persists on the session and survives the interactive-attempt TTL sweep, so the 7.2b-3 verify-share FFI resolves the group key by session_id with no new persistence.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
mswilkison and others added 5 commits June 13, 2026 17:46
Match the 7.2b-1 implementation change (PR #4055): the InteractiveAggregate completion marker becomes a completion record (attempt_id -> aggregate signature hex), and a repeat aggregate returns the stored signature (idempotent) rather than an 'already aggregated' error. Addresses a Codex review finding that the reject-based marker made a successfully-computed signature unrecoverable from the engine if the host lost the FFI response. §11 acceptance updated accordingly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
- open-questions doc: drop the stale 'pending owner sign-off' status; the design is signed off and recorded as gates-doc Decision Log entry 9 in the same PR, so the binding docs no longer contradict each other.

- design note: reconcile terminology - the section 6 retitle to 'completion record' had left six 'completion marker' references in sections 1/4/7/9; all now read 'completion record'.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the 7.2b-1 fix (PR #4055): re-emission of the completion record is validated against the request's tweaked key/message - attempt_id does not bind the taproot root, so a reused attempt_id with different aggregate inputs is rejected rather than handed a non-verifying signature (Codex re-review finding). Drops the now-inaccurate 'or erroring' on the same-inputs path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The open-questions doc's closing paragraph still said owner sign-off was pending and 7.2b-1 could only start afterward, contradicting the signed-off status header + Decision Log entry 9 added in the same PR. Updated to reflect the sign-off and that 7.2b-1 is implemented (PR #4055).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Match the implementation decision (PR #4055): section 6 reverts from idempotent signature re-emission to a completion MARKER that rejects re-aggregation; recovery of a lost signature is via a fresh ROAST attempt. Records why the re-emit variant was dropped (concurrent-race divergence, request-input binding, lost-shares recovery gap). section 11 acceptance + 'completion record'->'completion marker' terminology updated across the design + open-questions docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@mswilkison mswilkison merged commit 9d0accf into extraction/frost-signer-mirror-2026-05-26 Jun 14, 2026
19 checks passed
@mswilkison mswilkison deleted the docs/phase-7-2b-package-envelope-design-2026-06-13 branch June 14, 2026 00:43
mswilkison added a commit that referenced this pull request Jun 14, 2026
#4055)

## Phase 7.2b-1: InteractiveAggregate completion marker

First implementation PR of Phase 7.2b (design signed off in #4054 —
gates-doc Decision Log entry 9). **Engine-side only — no blame, no
envelopes** — preserving the crypto-only engine boundary (the Q1
correction): all envelope verification and authoritative blame remain
Go-side. Targets the mirror branch directly and is independent of #4054
(the code touches no docs; #4054 touches no source).

### What it does
Adds a durable per-attempt "aggregated" marker so re-aggregating a
completed interactive attempt is rejected
(`InteractiveAttemptAlreadyAggregated`) rather than recomputed (frozen
Phase 7 spec; design §6). This is the only engine-side state 7.2b adds.

### Changes
- **`errors.rs`**: new `EngineError::InteractiveAttemptAlreadyAggregated
{ session_id, attempt_id }` — code
`interactive_attempt_already_aggregated`, recovery class `recoverable`
(benign duplicate; the engine is healthy).
- **`engine/state.rs`**: `aggregated_interactive_attempt_markers:
HashSet<String>` on `SessionState`.
- **`engine/persistence.rs`**: mirrored `Vec<String>` on
`PersistedSessionState` with `#[serde(default, skip_serializing_if =
"Vec::is_empty")]` (loads pre-7.2b state; writes nothing when empty),
bounded via the existing consumed-registry helpers, wired through both
`TryFrom` conversions.
- **`engine/interactive.rs`**: `interactive_aggregate` pre-checks the
marker before recomputing; keeps the aggregation lock-free; then
re-acquires the lock, **re-checks** (a concurrent-duplicate race guard),
inserts the marker, and persists with rollback-on-failure — mirroring
the Round2 consume-before-release pattern — before reporting success.

### Durable-retention confirmation (design §9)
The §9 question — is the group public key package durably retained
beyond the signing-session TTL (needed by the later 7.2b-3 verify-share
FFI)? — is **already satisfied**: `dkg_public_key_package` lives on the
persisted session and `sweep_expired_interactive_state` clears only the
live attempt's nonces. No new persistence is added here.

### Tests
- Re-aggregating a completed attempt is rejected with the correct code.
- The completion marker survives `simulate_process_restart_for_tests()`
+ reload (persistence round-trip).
- Error code / recovery class / message format pinned.

### Verification
`cargo fmt`, `cargo clippy --all-targets --all-features -- -D warnings`
clean, full suite **274 lib pass / 0 fail / 1 ignored**, Phase-5 chaos
suite all scenarios pass.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
mswilkison added a commit that referenced this pull request Jun 14, 2026
… foundation) (#4056)

## Phase 7.2b-2: signed signing-package envelope — wire foundation

First increment of Phase 7.2b-2 (design note §2/§6/§7, merged in #4054).
Adds the coordinator-distributed signed signing-package envelope's
**wire foundation**; the coordinator/member logic follows on this branch
(see below).

### What this adds
- **Protos** (`pkg/frost/roast/gen/pb/signing_package.proto`):
`SigningPackageBody {attempt_context_hash, coordinator_id,
signing_package, taproot_merkle_root}` and `SignedSigningPackage {body,
coordinator_signature}` — generated into the existing `gen/pb` dir
(protoc-gen-go v1.36.3; same dir as `evidence.proto`, so no new
Dockerfile gen-allowlist entry).
- **`SigningPackage` Go type** (`signing_package.go`): the same
signed-body, sign-what-you-transmit / verify-what-you-received
discipline as the evidence envelopes (`wire.go`). `SignableBytes`
marshals the body once and caches it; `Unmarshal` retains the received
body + envelope verbatim (the coordinator signature is checked over
exactly those bytes); `Validate` does structural checks (32-byte attempt
hash, non-zero coordinator, non-empty bounded signing package,
0-or-32-byte root, signature cap).

### Why byte-preservation
The point of the envelope is that signature validity never depends on a
serializer's canonical form (across protobuf versions or the Go↔Rust
boundary). Pinned by tests: received bytes survive re-broadcast
verbatim, a hand-crafted non-canonical encoding is preserved, key-path
and script-path roots round-trip, `Validate` rejects malformed packages,
and `Marshal` refuses an unsigned package.

### Not in this increment (follows on this branch)
- Coordinator-side: sign `SigningPackageBody` with the operator key +
distribute.
- Member-side: authenticate (elected `coordinator_id` per RFC-21 Annex A
+ signature under that key + attempt-hash match + `taproot_merkle_root`
== live root) and retain (incl. retain-on-reject for divergent
envelopes).
- Context-bound member-authenticated Round2 share submission (signed
body covers attempt_context_hash + package/envelope hash + share) — the
hard prerequisite gating blame (7.2b-4).
- The engine never sees this envelope; blame adjudication stays Go-side
(frozen spec §6).

### Verification
`gofmt`, `go build`, `go vet`, and the full `pkg/frost/roast/...` test
suite are green.

🤖 Generated with [Claude Code](https://claude.com/claude-code)
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