diff --git a/pkg/tbtc/signer/docs/phase-7-2b-open-questions-discussion.md b/pkg/tbtc/signer/docs/phase-7-2b-open-questions-discussion.md new file mode 100644 index 0000000000..fd44dd35a4 --- /dev/null +++ b/pkg/tbtc/signer/docs/phase-7-2b-open-questions-discussion.md @@ -0,0 +1,277 @@ +# Phase 7.2b Open Questions — Options, Tradeoffs, Recommendations + +Date: 2026-06-13 +Status: SIGNED OFF 2026-06-13 — recorded as gates-doc Decision Log entry +9. Reviewed (Gemini + Codex concurred; recommendations resolved — see the +Decision Log at the end); Q1 was corrected back to the frozen spec on a +converging reviewer P1. +Companion to: `phase-7-2b-package-envelope-design.md` + +This doc works the three open questions that gate the 7.2b +implementation into explicit options with tradeoffs and a recommendation +each. They are now **resolved** (Gemini + Codex reviewed; see the +Decision Log at the end) — each question's "RESOLVED" block records the +decision and its reasoning. This is a decision record, not an open +solicitation. + +A framing distinction that runs through all three, stated once: + +> The coordinator is a node and may itself be the adversary. So +> "detect a bad input" splits into two jobs with different trust +> assumptions: (a) **protect honest members from false blame** when the +> coordinator's inputs don't match what members signed; and (b) **prove +> a malicious coordinator equivocated** so it can be excluded. Neither +> can be entrusted to the coordinator's engine: as Q1 resolves, the +> engine emits only mathematical *candidate* culprits, and BOTH jobs are +> enforced at the members / the f+1 accuser quorum — job (a) by +> re-checking an accused share against the accused member's own retained +> envelope, job (b) by comparing members' retained envelopes. Several +> options below are really about which of these two jobs they serve. + +--- + +## Q1 — How is a failing share bound to what the member signed? + +**Problem.** InteractiveAggregate (7.2a) fails closed without blame +because a coordinator aggregating against a different package/root than +members signed makes honest shares fail and would frame them. To name a +culprit soundly, the engine must distinguish "member produced a bad +share for the agreed package" from "coordinator fed a different +package." A FROST share cryptographically binds to the package it was +produced over, so a failure alone cannot tell these apart — the engine +needs evidence of what each member was told to sign. + +### RESOLVED (post-review) — the engine never touches envelopes; blame adjudication is Go-side + +Both reviewers returned a converging **P1** that my pre-review lean +(Option A: *the engine* verifies member envelopes at aggregate) was +wrong, on two independent grounds: + +- **Gemini (architecture):** the Rust engine holds only FROST/Schnorr + threshold key material — it has no operator-key registry, so it + *cannot* verify a coordinator's operator signature on an envelope. + Byte-comparing bodies without verifying the signature lets a malicious + *member* forge a body to dodge blame. Pushing operator keys into the + engine to fix that would blow the signing-secret boundary the sidecar + exists to hold (gates-doc decision 2). Verification belongs in the Go + host, which owns the registry, the network identities, and the exact + bytes it distributed. +- **Codex (authentication direction):** even if the engine *could* + verify it, `SignedSigningPackage` is signed by the **coordinator, not + the member**. A malicious coordinator distributes P′ to member A, + collects A's share over P′, then aggregates against P with its own + validly-signed P-envelope: A's share fails against P, the envelope body + equals P, and the equality check frames honest A. Sound *member* blame + needs a *member*-authenticated binding of share↔body, never a + coordinator signature. + +The two compose into the design the **frozen spec already specifies** +(§5 item 4 and §6) — my 7.2b design note had drifted from it, so the +reviewers' P1 is really "return to the freeze": + +1. **Engine = pure FROST math.** `InteractiveAggregate` verifies each + share against the member's verifying share (public, from the DKG key + package the engine already holds) and returns the mathematically + failing members as *candidate* `culprits`. No envelopes, no operator + signatures, no network identity in the engine. This makes the 7.2b-3 + engine change *smaller* than the note implied. +2. **Go host = envelope verification + retention.** Members verify the + coordinator's operator signature and retain the exact received + `SignedSigningPackage` bytes (§6, #4040 pattern). The coordinator + signature is the right artifact *here* — for proving *coordinator* + equivocation (job (b)), where coordinator-authentication is exactly + what you want — not for attributing member fault. +3. **Authoritative blame = Go, at the f+1 accuser quorum, against the + member's retained bytes.** The engine's candidate culprits are only a + trigger. Final exclusion re-checks each accused share against *the + package envelope that member signed over* (its retained received + bytes), never a coordinator-submitted or reconstructed package (§6, + verbatim). Under Codex's attack, A's share verifies against its + retained P′-envelope, so A is exonerated and the coordinator's two + divergent signed bodies for one attempt-context convict *it*. + +Soundness comes from **Round2 check (f)** — already shipped in 7.1: a +member signs only over a package carrying its own true commitment — plus +the quorum re-check above. A single malicious coordinator can neither +manufacture an f+1 quorum nor make an honest member's retained-envelope +re-check fail. + +### Corrected options (the real axis is *where adjudication lives*, not *what crosses the FFI*) + +| Option | Engine role | Blame adjudication | Sound vs malicious coordinator? | Verdict | +|---|---|---|---|---| +| **Engine verifies envelopes** (my pre-review A *and* C) | verify operator sigs / body-hashes at aggregate | in-engine | **No** — engine has no registry (member-forgeable) and a coordinator-sig is the wrong auth direction (frames honest member) | **Rejected** (both reviewers, P1) | +| **Engine pure-math + Go quorum adjudication** (frozen §5.4/§6) | verify share vs verifying share → candidate culprits | Go host, at f+1 quorum, vs member's retained bytes | **Yes** — Round2 (f) + quorum re-check; engine stays crypto-only | **Adopted** | + +### Hard prerequisite (Codex P1): member-authenticated share submission + +For the quorum to attribute "this failing share is A's," A's share +submission to the coordinator MUST be member(operator)-authenticated. +This is not a nice-to-have to confirm — it is **load-bearing for blame +soundness**: without it a malicious coordinator submits random bytes +labeled "A's share," the engine returns A as a *candidate* culprit (the +bytes fail share-verification), and the quorum re-checks bytes A never +sent, framing A. Therefore authoritative blame (7.2b-4) MUST NOT be +enabled until member→coordinator share submissions are authenticated +(reuse the existing #4040 sign-what-you-transmit envelope). **The signed +body must cover the attempt context AND the signing-package/envelope hash, +not just the share bytes** (Codex follow-up P1): otherwise a coordinator +replays an old A-signed share into a different attempt/package and the +quorum re-checks bytes A never submitted for that context — framing A. It +is a Go-layer property (not an engine one) and is part of the +implementation sequence (7.2b-2) and acceptance criteria (neither an +unattributable share nor a replayed A-signed share can make A a culprit). + +A second hard prerequisite rides with it (Codex P1, companion design +note §2): members MUST verify `taproot_merkle_root` against their live +session root before signing. The attempt context does not carry the +root, so the retained envelope only faithfully describes "what the member +signed" — the thing the quorum re-checks against — if the member rejected +any envelope whose root diverged. + +--- + +## Q2 — Where does cross-member equivocation comparison run? + +**Problem.** Per the framing above, *proving* a malicious coordinator +equivocated (and excluding it) cannot run in the coordinator's engine. +Members each hold a coordinator-signed envelope; a malicious coordinator +that signed two different bodies is caught only when members pool and +compare their envelopes. The question is the transport for that pooling. + +### Options + +| Option | Mechanism | Detection latency | Complexity | Handles malicious coordinator? | +|---|---|---|---|---| +| **A. Opportunistic gossip** — members broadcast received envelopes on a topic and compare continuously | early, before the attempt times out | high — new topic, async-consistency caveats (RFC-21 warned against assuming synchronous gossip) | yes | +| **B. At the f+1 accuser-quorum step** — retain now; compare only when an exclusion is being decided | late (at exclusion time) | low — reuses the existing #4029 quorum machinery | yes | +| **C. Coordinator-side at aggregate only** | n/a for malicious coordinator | n/a | low | **no** — a malicious coordinator won't self-incriminate | + +### RESOLVED (post-review) — retention now; comparison via Option B (both reviewers concur) + +7.2b lands the *retention* (each member persists the exact +`SignedSigningPackage` it received) regardless. The comparison transport +is **Option B**: feed retained envelopes into the existing f+1 +accuser-quorum exclusion path (#4029) — where an exclusion is actually +decided, and which RFC-21 already chose over synchronous-gossip +assumptions. This is also exactly the adjudication site Q1's resolution +relies on. + +Gemini's confirming argument closes the "what if the coordinator +equivocates but stays silent" gap: a malicious coordinator that +equivocates package bodies but emits no blame merely causes the attempt +to time out — a *liveness* failure indistinguishable from the +coordinator dropping messages, which the existing RFC-21 rotation +machinery already handles by moving to the next attempt. Proof is needed +only when the coordinator emits blame against an honest member, and there +the accused member defends itself by revealing the equivocation at the +exclusion step. So early/opportunistic gossip (Option A) is an +unnecessary optimization on the critical path, not a 7.2b requirement; +Option C (coordinator-side) cannot catch a malicious coordinator at all +and is excluded as a detection mechanism. + +--- + +## Q3 — Shape of the structured FFI error carrying culprits + +**Problem.** `ffi::error_result` serializes only `code`, `message`, +`recovery_class`, so the reintroduced `InvalidSignatureShare { culprits }` +would lose the culprit vector as structured data (#4052 P2). The Go +coordinator needs the culprits machine-readable to exclude the right +members. + +### Options + +| Option | Shape | Typing | Extensible | Churn | +|---|---|---|---|---| +| **A. Typed optional field** — add `culprits: Option>` (a small typed `blame` sub-struct) to `ErrorResponse` | strong | per-kind: a new structured error adds a new field | small, localized | +| **B. Generic `details: map`** | weak — callers parse untyped JSON | any structured error reuses it | medium; defines a general contract | +| **C. Encode culprits in the `message` string** with a parseable convention | none (stringly-typed) | no | tiny code, but it's the anti-pattern #4052 P2 named | + +### RESOLVED (post-review) — Option A (both reviewers concur) + +A typed optional field — `culprits: Option>` (a small typed +`blame` sub-object) on `ErrorResponse` — is safer and clearer than an +untyped JSON map, which would force the Go bridge into dynamic type +assertions and weaken the FFI contract. Culprits are the only structured +detail in flight (YAGNI: a generic `details` map can come later if a +*second* structured error ever needs one). The change touches the Rust +`ErrorResponse`, the C header, and the Go bridge decoder together. Use +the Go member-identifier **u16** form, consistent with the rest of the +wire surface — and note this pins the companion design note, which still +said `[]string` (Codex P2); that note is corrected to `[]u16`. + +--- + +## Decision Log (post-review) + +Gemini and Codex both reviewed this doc and the companion design note. +All three questions are resolved with reviewer concurrence; the only +substantive change from my pre-review lean is Q1, where both reviewers +returned a P1 that corrected it back to the frozen spec. + +| Q | Resolution | Reviewer status | +|---|---|---| +| Q1 binding | Engine does **pure FROST math** → candidate `culprits`; **all** envelope verification, retention, and authoritative blame re-check live in the **Go host at the f+1 accuser quorum**, against the member's retained received bytes (frozen §5.4/§6). Engine never sees envelopes or operator keys. | Gemini P1 + Codex P1 — adopted | +| Q2 equivocation | **Retention now; compare at the f+1 quorum step (B).** Gossip (A) deferred; coordinator-side (C) can't catch a malicious coordinator. | Both concur | +| Q3 FFI error | Typed optional `culprits: Option>` on `ErrorResponse` (A); generic details map deferred (YAGNI); design note pinned to `u16`. | Both concur | + +Re-review (multiple passes) folded in Codex **P1**s and **P2**s plus a +self-review item on the implementation shape — none changes the Q1/Q2/Q3 +decisions above: +- **Taproot root binding** (P1) — members MUST verify `taproot_merkle_root` + against their live session root before signing (the root is not in the + attempt context); otherwise the retained envelope misdescribes what was + signed and the quorum re-check misattributes blame. (design note + §2/§3/§11) +- **Member-authenticated share submission** (P1) is a hard prerequisite, + not a confirmation: authoritative blame must not be enabled until a + share is provably attributable to its member. (this doc, Q1 + prerequisite; design note §9/§11) +- **All-cheater detection** (P2) — 7.2b-3 must aggregate with + `CheaterDetection::AllCheaters`, not the default first-cheater path, or + the `culprits == entire subset` rule is unreachable and multi-share + failures are under-reported. Verified remedy: frost-core 3.0.0 already + collects all culprits in that mode (no reimplementation); the taproot + `aggregate_with_tweak` wrapper does not expose the mode, so apply the + tweak + `aggregate_custom(AllCheaters)`. (design note §4/§9) +- **Elected-coordinator check** (P2) — members must verify `coordinator_id` + is the *elected* coordinator and the signature is under that key; the + attempt hash is public, so any operator could otherwise inject packages + and pollute the equivocation evidence. (design note §2/§3/§9/§11) +- **Retain-on-reject** (P2) — a member that refuses a root-divergent + envelope must retain it first, or §3 loses the bytes proving the + coordinator equivocated. (design note §2/§3/§11) +- **Tweak-aware, engine-delegated quorum re-check** (self-review) — the + quorum's per-share crypto re-verify is FROST math; it must be + tweak-consistent and is delegated to a stateless engine verify-share (Go + owns only the quorum policy), not reimplemented in Go. (design note + §4/§7/§9/§11) +- **Context-bound share authentication** (P1, follow-up) — the + member-authenticated share submission must bind the share to the attempt + context + package/envelope hash, not just the share bytes, or a + coordinator replays an old A-signed share into a different + attempt/package and frames A. (design note §4/§9/§11; this doc, Q1 + prerequisite) +- **Group key + selector in verify-share inputs** (P2, two passes) — the + verify-share FFI needs the **group verifying key** (tweaked for taproot), + not just the per-member verifying share, to compute the challenge/binding + factors, AND a `session_id`/wallet **selector** to resolve the *right* + key in a multi-session engine. That key must come from + **durably-retained wallet DKG material** outliving the signing-session + TTL sweep, since the quorum re-check can run after the session is gone + (or, equivalently, the Go quorum passes the public key package explicitly + — it is public, sound under the f+1-independent-accuser model). (design + note §4/§7/§9/§11) + +These refine 7.2b's implementation shape without changing the frozen +Phase 7 spec — Q1's resolution in fact *realigns* the design docs to it. +Companion design-note corrections applied alongside this doc: §4 (engine +emits candidate culprits, Go adjudicates — not "engine verifies the +coordinator signature"), §5 (`[]u16`), §6 (Round2 record is local +bookkeeping, not the blame-binding artifact), §7 (blame adjudication +moves to the Go column), §10 (superseded body-hash proposal removed). + +**SIGNED OFF** (MacLane, 2026-06-13), recorded as gates-doc Decision Log +entry 9; 7.2b-1 (the InteractiveAggregate completion marker; **no** +envelope plumbing in the engine) followed and is implemented in PR #4055. diff --git a/pkg/tbtc/signer/docs/phase-7-2b-package-envelope-design.md b/pkg/tbtc/signer/docs/phase-7-2b-package-envelope-design.md new file mode 100644 index 0000000000..2d500d9a6f --- /dev/null +++ b/pkg/tbtc/signer/docs/phase-7-2b-package-envelope-design.md @@ -0,0 +1,363 @@ +# Phase 7.2b: Signed Package Envelopes, Bound Blame, and Vectors + +Date: 2026-06-13 +Status: Design note (scopes the 7.2b implementation PRs); open questions +RESOLVED post-review — see §10 and the open-questions discussion doc. Key +correction: the engine does pure FROST math and NEVER verifies envelopes; +all envelope verification + blame adjudication is Go-side. +Owner: Threshold Labs +Scope: the signed-body signing-package envelope (frozen spec section 6), +the envelope-bound attributable blame deferred from 7.2a, the FFI +structured-culprit payload, the InteractiveAggregate completion marker, +and the cross-language vectors. Builds on merged 7.1 (#4051) + 7.2a +(#4052, mirror). + +## 1. Why this phase exists + +7.2a ships InteractiveAggregate but **fails closed without naming +culprits** on an invalid share. That was deliberate: the engine cannot +yet bind the aggregate's public inputs (signing package, taproot root) +to what each member actually signed at Round2, so a coordinator +aggregating against a different package/root would make honest shares +fail verification and frame their members (Codex P1 on #4052). The +frozen spec ties attributable blame to share verification (section 5, +item 4) AND ties the binding that makes it trustworthy to signed +package envelopes (section 6) — with the engine doing pure share-math and +the binding/adjudication living in the Go host (see §4 and the +open-questions doc Q1). 7.2b builds the foundation (the envelopes, +Go-side) and the feature (the bound blame) together, so blame is never +emitted without the evidence that backs it. + +This is the recurring lesson made concrete: do not ship the security +feature (blame) ahead of its foundation (the binding). + +## 2. The signed package envelope + +Mirror the proven #4040 signed-body pattern +(`pkg/frost/roast/gen/pb/evidence.proto`): the bytes are signed once, +embedded verbatim, and re-verified against exactly what was received - +no canonical-form dependence. + +New wire types (Go, `pkg/frost/roast/gen/pb/`): + +``` +// The coordinator-distributed signing package for one attempt. +message SigningPackageBody { + bytes attempt_context_hash = 1; // binds package to the attempt + uint32 coordinator_id = 2; + bytes signing_package = 3; // the frost SigningPackage bytes + bytes taproot_merkle_root = 4; // empty = key-path +} + +// On-wire: exact signed body + the coordinator's operator signature. +message SignedSigningPackage { + bytes body = 1; + bytes coordinator_signature = 2; +} +``` + +The coordinator signs `SigningPackageBody` with its operator key and +distributes `SignedSigningPackage` to the chosen subset. Each member: +1. **authenticates the envelope as genuine coordinator evidence for this + attempt**: `coordinator_id` equals the attempt's *elected* coordinator + (RFC-21 Annex A), the signature verifies under that elected + coordinator's operator key, and `attempt_context_hash` matches the live + attempt. If any fails the envelope is not attributable to the + coordinator — reject WITHOUT retention (forgeable noise, not evidence); +2. **retains the exact received envelope bytes** — for every envelope that + passed step 1, BEFORE the sign/no-sign decision, so a divergent + envelope is still available to the §3 cross-member comparison; +3. checks `taproot_merkle_root` equals the live session/signing root + (empty for key-path); if it diverges, **refuse to sign** and flag the + (already-retained) envelope as reject/equivocation evidence; +4. otherwise produces its Round2 share over the `signing_package` in that + body, under the (now root-verified) session root. + +Three acceptance checks are load-bearing here (Codex P1+P2): +- **Elected-coordinator (step 1):** `attempt_context_hash` is public, so + any operator could sign a body carrying it. Without checking + `coordinator_id` against the elected coordinator AND verifying under + that specific key, a non-elected operator could inject packages and + pollute the retained evidence — and the §3 equivocation proof requires + both divergent bodies to carry the *same elected coordinator's* + signature. +- **Root binding (step 3):** the attempt context does NOT carry the root, + but Round2 signs under the session root, so a divergent root would make + the retained envelope misdescribe what the member signed and the quorum + re-check misattribute blame. +- **Retain-before-refuse (steps 2–3):** the member must retain a divergent + envelope *before* refusing to sign it, or §3 loses the signed bytes that + prove the coordinator equivocated the root. + +## 3. Equivocation detection (extends #4044) + +A coordinator that distributes *different* package bodies to different +members is the framing vector 7.2a could not defend against. With +envelopes it is self-incriminating: each member retains the exact +`SignedSigningPackage` it received (whether or not it signed over it — +§2 step 2). Two members holding +envelopes with the same `attempt_context_hash` but different bodies, each +carrying the coordinator's signature, are a proof of coordinator +equivocation - the same shape as #4044's `EquivocationEvidence`, now +over package envelopes rather than evidence snapshots. Because +`taproot_merkle_root` is a field of the signed body, distributing +different roots is the same equivocation — and a member that refuses a +root-divergent envelope still **retains it as evidence** (§2 step 2), so +the comparison has both signed bodies even though one was never signed +over. Both retained bodies must carry the *elected* coordinator's +signature (§2 step 1) for the proof to convict it. The cross-member +comparison is the RFC-21 Go layer's job (scaffold), not the engine's. + +## 4. Bound attributable blame (reintroduces what 7.2a removed) + +InteractiveAggregate's per-share verification becomes attributable ONLY +when bound to what the member signed: + +- The authoritative binding is the member's **retained envelope**, held + in the Go layer (it carries the package AND the root — §2). The engine + itself does NOT need to record what it signed for the blame flow: + nothing in the corrected design consumes such a record (the quorum + re-checks Go-retained bytes; the completion marker is attempt-keyed). + 7.2b-1 should add an engine-local Round2 record ONLY if a concrete + consumer is identified; absent one, the engine-side state is just the + completion marker (section 6). +- At aggregation the **engine does pure FROST math**: it verifies each + share against the member's verifying share (public, from the DKG key + package it already holds) and returns the mathematically failing + members as *candidate* culprits — + `EngineError::InvalidSignatureShare { culprits }`. The engine does NOT + see envelopes or operator signatures (it has no operator-key registry; + open-questions doc Q1). Authoritative blame is adjudicated in the **Go + host at the f+1 accuser quorum**, which re-checks each accused share + against the `signing_package` AND `taproot_merkle_root` inside *the + envelope that member signed over* (its retained received bytes — both + fields are what the member signed), never a coordinator-submitted or + reconstructed package. The cryptographic part of that re-check — does + this share verify against the signing_package under the **group + verifying key tweaked by taproot_merkle_root**? — is FROST math and is + **delegated to the engine** (a tweak-aware verify). The Go quorum + supplies (signing_package, taproot_merkle_root, the accused member's + identifier, the share) **plus a `session_id`/wallet selector**, so the + engine resolves the *right* canonical public key package (group key + + verifying shares) in a multi-session engine (Codex P2) and applies the + tweak. Because the quorum re-check can run *after* the interactive + session is TTL-swept (frozen §5), the engine MUST resolve that key from + **durably-retained, wallet-scoped DKG material** that outlives the + signing session — never the ephemeral session object. (Sound + alternative: the Go quorum passes the canonical public key package + explicitly; it is public, so only secret material stays off the FFI, and + this keeps verify-share a pure function — sound under the + f+1-independent-accuser model where each accuser verifies with its own + canonical DKG copy.) The Go quorum owns the policy (f+1 threshold, + envelope/equivocation comparison, which shares to re-check), and never + hands the engine the envelope or operator keys. A candidate culprit + becomes authoritative blame ONLY for a share **provably submitted by the + accused member for THIS attempt and package** — the member-authenticated + submission must bind the share to the attempt context + + signing-package/envelope hash, not just the share bytes (see §9/§11, a + hard prerequisite), else a coordinator can replay an old A-signed share + into a different attempt and frame A. A share the coordinator could have + fabricated — or replayed — names no one. The + coordinator's operator signature on the envelope is the artifact that + convicts an *equivocating coordinator* (two divergent signed bodies for + one attempt-context), not what attributes member fault. +- If a member's retained envelope diverges from the aggregated package + (equivocation) or a member signed a different body, the quorum re-check + yields NOT member blame but coordinator/input fault — a distinct + non-attributive outcome. This is the precise fix for the 7.2a + forgeable-blame finding: an honest member whose share fails only + because the coordinator aggregated a package it never signed is + exonerated by its own retained bytes. +- Refinement carried from the #4052 review: `culprits == the entire + subset` is treated as suspect (a coordinator/config error is far more + likely than every member simultaneously cheating) and is reported as a + coordinator-side error, not universal member blame. **For this rule to + be reachable, 7.2b-3 MUST request all-cheater detection** (Codex P2): + the plain `frost::aggregate` / `aggregate_with_tweak` hard-code + `CheaterDetection::FirstCheater` (verified in frost-core 3.0.0), so + they report only the first invalid share and the full-subset condition + could never trigger (multi-share failures would also be under-reported, + forcing one-cheater-per-attempt exclusion grinds). No reimplementation + is needed: frost-core 3.0.0 already collects every culprit under + `CheaterDetection::AllCheaters` (its `detect_cheater` extends an + `all_culprits` vec over all shares). Taproot wrinkle to pin in 7.2b-3: + `frost_secp256k1_tr::aggregate_with_tweak` does NOT expose the mode (it + delegates to first-cheater), so the engine applies the tweak itself + (`public_key_package.tweak(merkle_root)`, as the wrapper does + internally) and calls `frost_core::aggregate_custom(…, + CheaterDetection::AllCheaters)` on the tweaked package — confirm + `.tweak()` is callable from the engine, else reproduce the tweak or + request an upstream `aggregate_with_tweak_custom`. Soundness does not + hinge on this engine heuristic: the authoritative + all-honest-vs-coordinator distinction remains the Go quorum re-check + against retained envelopes (above). + +## 5. FFI structured-culprit payload (Codex P2 on #4052) + +`ffi::error_result` currently serializes only `code`, `message`, +`recovery_class`, so a culprit vector would be lost as structured data +and the Go coordinator would have to parse the Display string. 7.2b adds +a typed optional field — `culprits: Option>` (the Go +member-identifier form — open-questions doc Q3) — to the FFI error +response so the engine's *candidate* culprits are machine-readable. They +are candidates: the Go host adjudicates final blame at the f+1 quorum +(section 4). A typed field, not a generic `details` map, keeps the FFI +contract strong (YAGNI); it must be reflected in the C header and the Go +bridge decoder. + +## 6. InteractiveAggregate completion marker + +Deferred from 7.2a: a persisted per-attempt completion marker +(`aggregated_interactive_attempt_markers: HashSet` on +`SessionState`, mirrored as `Vec` in `PersistedSessionState`, +bounded like the consumed markers). Re-aggregating a completed attempt is +rejected with `InteractiveAttemptAlreadyAggregated` rather than +recomputed — re-aggregation is not a recovery path. A host that loses an +aggregate signature recovers it the way ROAST recovers any failed +attempt: by starting a fresh attempt, not by replaying the engine. So the +engine neither retains nor re-emits the signature; the marker only records +"this attempt is complete" (frozen spec section 6). The marker is durable +(markers-only durability) so a completed attempt stays rejected across a +restart, and an empty attempt_id is rejected before the marker is written +(the reload path rejects an empty key, so a malformed write must not be +able to persist one). Not security-load-bearing — the blame binding is +Go-side (section 4) — so this marker is the only engine-side state 7.2b +adds. + +(A richer "re-emit the stored signature on replay" variant was explored +and dropped after review: keyed by attempt_id it accumulated avoidable +surface — concurrent-race signature divergence, request package/root +binding, and a recovery path that still required the host to have retained +the collected shares — for a benefit ROAST's native retry already +provides.) + +## 7. Go vs Rust split + +- **Go (scaffold branch)**: the `SigningPackageBody`/`SignedSigningPackage` + protos + gen (mind the Dockerfile gen-COPY allowlist - the #4040 CI + gotcha), coordinator-side package signing + distribution, member-side + authenticate (elected-coordinator + signature) / retain (incl. + retain-on-reject), cross-member equivocation comparison (extends + #4044), and the **blame-adjudication policy** at the f+1 accuser quorum + — selecting which shares to re-check and the exclusion decision, but + delegating the per-share crypto re-verify to the engine. The Go bridge + decoder for the new structured FFI error. +- **Rust (mirror branch)**: the FFI candidate-`culprits` payload (pure + FROST math via `CheaterDetection::AllCheaters`, no envelopes) + C + header, a **tweak-aware verify-share** entry the quorum re-check calls + (caller-supplied public inputs: signing_package, taproot_merkle_root, + member identifier, share, + a `session_id`/wallet selector; the engine + resolves the canonical **group verifying key** + verifying shares from + durably-retained wallet DKG material — surviving the session TTL sweep — + and applies the tweak; never the envelope or operator keys), the + completion marker, and the engine-side vectors. The engine never + verifies envelopes or operator signatures. + +## 8. Cross-language vectors (frozen spec item 9) + +Pin the new wire structs across languages: `SigningPackageBody` +canonicalization, `SignedSigningPackage` byte-preservation/verbatim- +embedding, and an equivocation-detection vector. Regenerate via the +established discipline and byte-copy to +`pkg/tbtc/signer/testdata/`; treat regeneration as a protocol-change +event. + +## 9. Suggested sub-PR sequence + +1. **7.2b-1 (mirror)**: the InteractiveAggregate completion marker + (persistence plumbing only; no blame, no envelopes). Self-contained. + (No engine-local Round2 package-hash record unless §4 identifies a + consumer — the corrected design has none.) + **Durable-retention question (below) CONFIRMED satisfied + (2026-06-13):** the group public key package the 7.2b-3 verify-share + FFI needs already outlives the signing session. `SessionState` + holds `dkg_public_key_package` (group key + verifying shares), it is + persisted as `dkg_public_key_package_hex` in `PersistedSessionState` + (rehydrated/serialized in the persistence `TryFrom`s), and + `sweep_expired_interactive_state` clears ONLY the live attempt's + nonces (`interactive_signing`), explicitly retaining the session's + DKG material. So a post-sweep f+1 quorum re-check can still resolve + the canonical group key by `session_id` — no new wallet-key + persistence is needed in 7.2b-1, only the completion marker. +2. **7.2b-2 (scaffold)**: `SignedSigningPackage` protos + gen + + coordinator signing/distribution + member authenticate (elected + `coordinator_id` + signature under that key + attempt hash + the + `taproot_merkle_root` check, §2) / retain (incl. retain-on-reject for + divergent envelopes) + **member-authenticated Round2 share submission + bound to the attempt context + signing-package/envelope hash** (reuse + the #4040 sign-what-you-transmit envelope; the signed body MUST cover + (attempt_context_hash, package/envelope hash, share) so a share is + provably from its claimed member AND not replayable into another + attempt/package — a hard prerequisite for blame, Codex P1×2). Wire + + `wire_test.go` byte-preservation, no engine change yet. +3. **7.2b-3 (mirror)**: FFI structured-error payload (`culprits: []u16`) + + C header + the pure-FROST candidate-`InvalidSignatureShare` in + InteractiveAggregate (no envelope handling in the engine), aggregating + with **`CheaterDetection::AllCheaters`** (tweak-aware — §4) so the full + culprit set is reported, not just the first; plus the **stateless + tweak-aware verify-share** FFI the 7.2b-4 quorum re-check calls + (caller-supplied public inputs: signing_package, taproot_merkle_root, + member identifier, share, + a `session_id`/wallet selector; the engine + resolves the canonical public key package = **group verifying key** + + verifying shares from durably-retained wallet DKG material that outlives + the session TTL sweep, and applies the tweak — the group key is required + for the challenge/binding factors, the selector disambiguates + multi-session lookups, Codex P2). The wallet public key package is + durably retained beyond the signing-session TTL (CONFIRMED in 7.2b-1: + `dkg_public_key_package` persists on the session and survives the + attempt-nonce sweep), so the verify-share FFI resolves it by + `session_id` with no extra persistence. +4. **7.2b-4 (scaffold)**: cross-member equivocation comparison + (extends #4044) + the **blame-adjudication policy** (quorum re-check — + the per-share crypto re-verify delegated to 7.2b-3's tweak-aware + verify-share FFI, the exclusion decision in Go) + the Go bridge decoder + for the structured error. **Gated on 7.2b-2's member-authenticated + share submission** — blame must not be enabled until a share is + provably attributable to its member. +5. **7.2b-5**: cross-language vectors, byte-copied both sides. + +Each is independently reviewable; the engine's candidate culprits +(7.2b-3) become *authoritative* blame only via the Go adjudication in +7.2b-4 (quorum re-check against retained envelopes from 7.2b-2). + +## 10. Open questions + +1. **~~Share carries its envelope, or coordinator collects them?~~ + RESOLVED (open-questions doc Q1, Gemini+Codex P1):** moot — the + *engine* never adjudicates envelopes at all. The engine returns + candidate culprits from pure FROST math; the Go host verifies operator + signatures and re-checks blame against each member's retained envelope + at the f+1 quorum. The earlier "engine binds against the body hash" + proposal is **withdrawn** — it was unsound (the engine has no + operator-key registry, and a coordinator signature is the wrong + authentication direction for member blame). +2. **~~Where does cross-member comparison run?~~ RESOLVED + (open-questions doc Q2, both reviewers concur):** retention now; + comparison at the f+1 accuser-quorum exclusion step (Option B). + Opportunistic gossip deferred. +3. **~~FFI structured-error shape?~~ RESOLVED (open-questions doc Q3, + both reviewers concur):** a typed optional `culprits: Option>` + field on `ErrorResponse` (not a generic `details` map), Go member-id + u16 form. + +## 11. Acceptance + +7.2b is done when: a member refuses to sign a root-divergent envelope but +**retains it as evidence** (test, Codex P1+P2); an envelope from a +non-elected coordinator is rejected without retention (test, Codex P2); a +coordinator equivocating package bodies (incl. the root) cannot produce +member blame, and is itself convicted from two retained +elected-coordinator bodies (Go quorum test); authoritative blame is gated +on context-bound member-authenticated share submission — neither a share +not provably from member A NOR an old A-signed share replayed into a +different attempt/package can make A a culprit (test, Codex P1); the +quorum's per-share re-check is **tweak-consistent**, verifying under the +tweaked group key (a valid taproot share verifies, an invalid one is +blamed; test); the re-check resolves the correct canonical group key via +its session/wallet selector and still succeeds after the signing session +is TTL-swept (multi-session + post-sweep test, Codex P2); a genuine bad +share yields machine-readable +*candidate* culprits over the FFI (engine test, `AllCheaters`) that the Go +quorum confirms as attributable `InvalidSignatureShare`; re-aggregating a +completed attempt is rejected (test) and an empty attempt_id is rejected +(test); and the cross-language vectors are pinned both sides. diff --git a/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md index 1949333dcc..166efc98c2 100644 --- a/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md +++ b/pkg/tbtc/signer/docs/roast-phase-5-security-rollout-gates.md @@ -185,6 +185,80 @@ architecture questions: follow-up outside this freeze; the audit scope must describe the DKG boundary as-is. +## Decision Log: Phase 7.2b Design Sign-Off (2026-06-13) + +9. **Phase 7.2b design SIGNED OFF (2026-06-13, MacLane):** the + package-envelope + bound-blame design note + (`phase-7-2b-package-envelope-design.md`, keep-core PR #4054) is + approved as the binding contract for the 7.2b implementation PRs, + after seven adversarial review passes (Codex + Gemini) whose final + two passes were clean on independent reads. The load-bearing + correction and the implementation gates folded across those passes: + + a. **Engine stays crypto-only (Q1 - the load-bearing correction).** + The Rust engine does pure FROST share-math and returns the + mathematically-failing members as *candidate* culprits; it never + verifies signing-package envelopes or operator signatures (it + holds no operator-key registry). All envelope verification and + *authoritative* blame adjudication live in the Go host at the + f+1 accuser quorum, which re-checks each accused share against + that member's *retained received bytes* - never a + coordinator-submitted or reconstructed package. This is a return + to the frozen spec §5.4/§6, not an amendment; the earlier + engine-binds-the-body-hash proposal is withdrawn as unsound + (wrong authentication direction for member blame). + b. **Cross-member comparison + retention timing (Q2).** Retain + received envelopes now; run the cross-member equivocation + comparison at the f+1 accuser-quorum exclusion step (Option B). + Opportunistic gossip deferred. + c. **FFI culprit payload (Q3).** A typed optional + `culprits: Option>` field on the FFI `ErrorResponse` + (Go member-id u16 form), not a generic `details` map. + d. **All-cheater detection.** 7.2b-3 aggregates with + `CheaterDetection::AllCheaters` so the full candidate-culprit set + is reported, not just the first. Because + `frost_secp256k1_tr::aggregate_with_tweak` hard-codes + first-cheater, the engine applies the taproot tweak itself + (`public_key_package.tweak(merkle_root)`) and calls + `frost_core::aggregate_custom(…, AllCheaters)` on the tweaked + package. + e. **Taproot root binding before signing.** Members verify + `SignedSigningPackage.taproot_merkle_root` equals the live + session root *before* producing a Round2 share (the root is not + in the attempt context but is what Round2 signs under), else the + retained envelope misdescribes what was signed and the quorum + re-check misattributes blame. + f. **Context-bound member-authenticated share submission.** A + candidate culprit becomes authoritative blame only for a share + provably submitted by the accused member for THIS attempt and + package: the member's signed share body must cover + (attempt_context_hash, signing-package/envelope hash, share), so + an old A-signed share cannot be replayed into a different + attempt to frame A. Hard prerequisite - 7.2b-4 must not enable + blame until this exists. + g. **Elected-coordinator check + retain-on-reject.** Members verify + the envelope's `coordinator_id` is the *elected* coordinator + (RFC-21 Annex A) and the signature verifies under that specific + key; a divergent but genuine-coordinator envelope is retained as + equivocation evidence *before* the member refuses to sign it. + h. **verify-share FFI contract.** The quorum's per-share crypto + re-check is delegated to a stateless tweak-aware verify-share FFI + taking only public inputs (signing_package, taproot_merkle_root, + member identifier, share, + a `session_id`/wallet selector); the + engine resolves the canonical group verifying key + verifying + shares from durably-retained, wallet-scoped DKG material that + outlives the signing-session TTL sweep, and applies the tweak - + never the envelope or operator keys. + + Standing gate unchanged: the external audit covering `frost-core` + 3.x + `frost-secp256k1-tr` remains a hard gate before mainnet TVL / + ECDSA retirement (entry 1). Next engineering step: 7.2b-1 - the + InteractiveAggregate completion marker; the design's §9 + durable-wallet-pubkey-package-retention question is confirmed + already satisfied (the DKG public key package lives on the persisted + session and survives the interactive-attempt TTL sweep), so 7.2b-1 + adds no new persistence beyond the marker. + ## Provisional Rollback Thresholds (Draft) These thresholds are intentionally conservative and should be tuned once the