Skip to content

feat(frost/roast): protobuf signed-body envelopes for transition evidence#4040

Merged
mswilkison merged 3 commits into
feat/frost-schnorr-migration-scaffoldfrom
feat/evidence-protobuf-signed-bytes-2026-06-12
Jun 12, 2026
Merged

feat(frost/roast): protobuf signed-body envelopes for transition evidence#4040
mswilkison merged 3 commits into
feat/frost-schnorr-migration-scaffoldfrom
feat/evidence-protobuf-signed-bytes-2026-06-12

Conversation

@mswilkison

Copy link
Copy Markdown
Contributor

Post-merge follow-up #4 from the June 2026 review stack (#4028#4035): replace json.Marshal as the canonical signed-bytes encoding for evidence snapshots/bundles — explicitly scheduled to land before Phase 7 wiring ossifies the format. (Items 2 and 3 landed as #4036/#4037 on the mirror branch; this is the Go-side sibling on the scaffold branch.)

Why now

The RFC-21 Layer B evidence signatures were computed over canonical JSON. That byte stability is a Go-implementation accident — field-order-stable encoding/json output — not a portable contract. The moment Phase 7 wires evidence verification into the Rust signer (or any second implementation appears), every verifier would need to replicate Go's exact JSON emission. No persisted or cross-component evidence exists yet, so the format can still change for free.

Design: sign what you transmit, verify what you received

New pkg/frost/roast/gen/pb/evidence.proto:

  • A snapshot travels as SignedLocalEvidenceSnapshot{body, operator_signature} where body is the serialized LocalEvidenceSnapshotBody — the operator signs those exact bytes.
  • A transition message travels as SignedTransitionMessage{body, coordinator_signature} whose TransitionMessageBody embeds every member's signed snapshot envelope verbatim (repeated bytes signed_snapshots) — the coordinator attests to the exact signed snapshots it assembled, in order.
  • Producers marshal a body exactly once, at signing time, and cache it; parsed messages retain received body/envelope bytes verbatim; verification always runs over exact received bytes. Nothing in the evidence chain is ever re-encoded, so signature validity never depends on any serializer's canonical form — across protobuf library versions or across languages. This deliberately sidesteps protobuf's own caveat that deterministic serialization is not canonical across implementations.
  • Marshal of a received message returns the received envelope verbatim — evidence bytes survive re-broadcast, including wire-legal but non-canonical encodings (pinned by a handcrafted reversed-field-order test).
  • CanonicalSnapshotBytes/CanonicalBundleBytesSignableBytes() accessors; the coordinator's first-write-wins conflict check now compares exact signed bytes.

Tests

  • Existing suite migrated off JSON fixtures: test-only encode helpers bypass production signing so every structural-rejection path (zero sender, bad hash length, unsorted/duplicate entries, oversize caps, bundle ordering/hash-binding) is still exercised at the wire level.
  • New wire_test.go pins the format's core properties: byte-preservation through unmarshal→re-marshal, verbatim snapshot-envelope embedding inside bundle bodies, producer-signed bytes == receiver-verified bytes, non-canonical-encoding survival, tampered-body verification failure.
  • go build ./..., go vet, gofmt clean; frost + tbtc package tests green. Generated with protoc 33.4 / protoc-gen-go v1.36.3 (matches the go.mod protobuf runtime v1.36.3).

Docs

RFC-21 "Evidence message format" decision rewritten: signed-body protobuf envelopes, with the retirement rationale for canonical JSON recorded.

Notes for reviewers

  • The in-memory model types (LocalEvidenceSnapshot, TransitionMessage) are unchanged apart from two unexported byte caches; all call sites kept their shapes.
  • Immutability contract: evidence fields must not be mutated after SignableBytes() is first computed (documented on the cache fields); the aggregation flow already treats snapshots as immutable post-receipt.
  • Phase 7 cross-language note: the Rust signer will verify operator/coordinator signatures over body bytes and parse them with any protobuf implementation — no canonicalization requirements transfer.

🤖 Generated with Claude Code

…ence

Post-merge follow-up #4 from the June 2026 review stack: replace
json.Marshal as the canonical signed-bytes encoding for evidence
snapshots and transition bundles before Phase 7 wiring ossifies the
format.

Design - sign what you transmit, verify what you received:
- new pkg/frost/roast/gen/pb/evidence.proto: a snapshot is
  SignedLocalEvidenceSnapshot{body, operator_signature} where body is
  the serialized LocalEvidenceSnapshotBody; a transition message is
  SignedTransitionMessage{body, coordinator_signature} whose body
  embeds every member's signed snapshot envelope VERBATIM
  (repeated bytes signed_snapshots)
- producers marshal a body exactly once at signing time and cache it;
  parsed messages retain the received body/envelope bytes verbatim;
  verification always runs over exact received bytes and nothing in
  the evidence chain is ever re-encoded - signature validity never
  depends on any serializer's canonical form, across protobuf library
  versions or across languages (the Phase 7 Rust signer verifies and
  parses these same bytes)
- CanonicalSnapshotBytes/CanonicalBundleBytes are replaced by
  SignableBytes() accessors; the coordinator's first-write-wins
  conflict comparison now compares exact signed bytes
- Marshal of a received message returns the received envelope
  verbatim, so evidence bytes survive re-broadcast; wire-legal but
  non-canonical encodings also survive (pinned by test)
- RFC-21 "Evidence message format" decision rewritten accordingly,
  with the rationale for retiring canonical JSON

Tests: existing suite migrated off JSON fixtures (encode helpers
bypass production signing to exercise structural rejection); new
wire_test.go pins byte-preservation through re-broadcast, verbatim
snapshot-envelope embedding in bundles, non-canonical-encoding
survival, and tampered-body verification failure. go build ./...,
go vet, gofmt clean; frost + tbtc package tests green.

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

coderabbitai Bot commented Jun 12, 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: 0cd4066c-4524-4fc7-945b-6a419fa8bbf7

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 feat/evidence-protobuf-signed-bytes-2026-06-12

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 2 commits June 12, 2026 10:01
The image strips committed **/gen/**/*.go (.dockerignore) and
regenerates protobufs in-image via make generate, which only sees the
gen directories explicitly COPY'd before it runs. Without this line the
new evidence.proto is absent at generation time and the later full COPY
restores only the .proto (not the stripped .pb.go), so go mod tidy
fails on the pkg/frost/roast/gen/pb import.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…o coupling

Review follow-ups on #4040 (own findings; Codex and Gemini passes were
clean):

- SignableBytes/Marshal docs now state the returned slice is the
  internal cache and must not be mutated - in-tree callers are all
  read-only, this pins the contract for future ones
- Makefile gen_proto comment points new proto packages at the
  Dockerfile gen-directory COPY allowlist, so the next proto package
  learns about the in-image regeneration coupling from the Makefile
  instead of from a CI failure

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@mswilkison
mswilkison merged commit b9dfffc into feat/frost-schnorr-migration-scaffold Jun 12, 2026
16 checks passed
@mswilkison
mswilkison deleted the feat/evidence-protobuf-signed-bytes-2026-06-12 branch June 12, 2026 14:53
mswilkison added a commit that referenced this pull request Jun 12, 2026
…al cache

Two P2 findings from Codex's re-review of the equivocation evidence path:

- observer panics could escape RecordEvidence/verifyOwnObservationsPresent
  and abort the protocol path - contradicting emitEquivocationEvidence's
  own "never fails" contract. The observer call is now wrapped in
  recover-and-log.
- snapshotEnvelopeForEvidence handed the observer the slice returned by
  Marshal, which is the snapshot's internal wire-envelope cache (the same
  must-not-mutate contract this stack pinned in the #4040 envelope work).
  An observer that retained and mutated it would corrupt the cached
  signed bytes used by later bundle aggregation. It now returns a
  defensive copy.

Regression tests: a panicking observer still yields ErrSnapshotConflict
from the protocol path; mutating the evidence envelope bytes leaves the
snapshot's cached Marshal output intact. Race detector clean.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
mswilkison added a commit that referenced this pull request Jun 12, 2026
…oints (#4044)

Implements the binding retention condition from today's decision log (PR
#4043): proof-carrying blame (follow-up item 7) is deferred until
production, **provided** telemetry/logging retain enough signed bytes to
diagnose whether targeted equivocation is occurring — otherwise the
revisit condition lacks data.

## What this adds

`EquivocationEvidence` events carrying the **exact signed snapshot
envelopes** (wire bytes verbatim — the #4040 format makes these
available at every detection point) for the three detections that exist
today:

- `snapshot_conflict` — a sender re-submits a *different* signed
snapshot for the same attempt to the coordinator. Both envelopes are
retained; two operator-signed bodies from the same sender for the same
attempt are self-incriminating, which is exactly the substrate item 7's
wire format will formalize.
- `own_snapshot_mutated_in_bundle` — a bundle carries this member's
snapshot with a signature that differs from what it submitted (both
envelopes retained).
- `own_snapshot_missing_from_bundle` — censorship detection (self
envelope retained).

Each event is logged in full (these are rare, and the bytes are the
diagnosis) and forwarded to a process-wide observer hook following the
repo's existing single-observer telemetry pattern, so hosts can persist
evidence into their telemetry stack. Emission is purely additive on the
existing error paths — encode failures degrade to nil fields with a log
line, never perturbing the protocol path.

## Deliberately out of scope

Cross-member comparison (a receiver checking a bundle's snapshot for
sender X against X's direct broadcast) — that's item 7 proper. These are
the detection points that exist today, instrumented so the production
deferral is honest.

Tests pin byte-exact envelope retention for all three kinds, and that an
idempotent identical re-submission emits nothing. `go build ./...`, vet,
gofmt clean; full frost suite green.

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

Phase 7.0: the spec-freeze candidate for the hardened interactive
two-round FROST signing session — the production signing path —
assembling the already-settled decisions (t-of-included-native per
decisions 5/6, sidecar-shaped API per decision 2, OS-randomness-only
production signing) into a precise contract, plus the deletion plan for
the frozen transitional deterministic-nonce path.

## The load-bearing design change: nonce custody moves inside the engine

Verified current state: the stateless primitives (`frost_ops.rs`) return
serialized `SigningNonces` to the Go host and accept them back at
`sign_share` — secret nonces cross the FFI twice, live in host memory
between rounds, and single-use is enforced by caller discipline only.
Calling `sign_share` twice with one nonce pair is the canonical FROST
key-extraction failure and nothing prevents it today.

The session layer (spec §4): nonces are generated and held in
session-scoped engine memory behind an opaque handle, consumed
atomically (durable consumption marker before the share leaves the
engine), zeroized on use, and **never persisted and never exported**.
Restart loses in-flight nonces by construction (attempt fails safe); the
cloned-state nonce-reuse class becomes structurally impossible; and
after Phase 7 no secret signing material transits the FFI in either
direction — which is also the audit story for that boundary.

## Also specified

- **Session API** (§5):
`InteractiveSessionOpen/Round1/Round2/Aggregate/Abort`,
idempotent-or-fail-closed, strict-mode attempt contexts only,
consumed-registry semantics carried from the coarse path,
transport-agnostic for the dlopen→sidecar swap.
- **t-of-included semantics** (§6): engine-side subset verification at
Round2 (own membership, subset-of-included, size t) so safety never
depends on coordinator honesty; signing packages ride #4040-style
signed-body envelopes extending the #4044 equivocation-evidence
retention; silent members cost zero attempts.
- **Deletion trigger made precise** (§7): three conditions defining
"interactive production path validated e2e" (Phase-5-equivalent suites
incl. consumed-nonce-marker persist-fault cases; a real testnet
t-of-included finalize through the full retry machinery; pinned
cross-language vectors). The `nonce.rs` freeze marker now points at this
section — the only code change in this PR, comment-only on the frozen
file.
- **Reserved, not built** (§8): bounded n−t+1 concurrency hooks
(attempt-scoped keys/handles).
- **Phasing** (§9): PR-sized 7.0–7.6 with repo-side mapping; #4007
sidecar scoping folds into 7.0 as an addendum.
- **Open questions with proposed defaults** (§10): package-distribution
channel, round-1 transport shape, responsive-subset policy, markers-only
durability — to be decided at freeze sign-off and recorded in the
Decision Log.

## Freeze process

Status is Proposed; it freezes on signer + keep-core owner sign-off per
§11, with the §10 defaults ratified or overridden in the gates-doc
Decision Log. The audit scope statement should reference this document
and name the §5 API as in-scope (decision 1 interaction).

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

## 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 #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 #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** (#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](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