From 06410b63abd1d1786119946bd68079acea50a347 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 13 Aug 2026 08:47:17 +0000 Subject: [PATCH 01/42] Reject unusable Ed25519 identity public keys Identity.Validate checked only that ed25519_public_key_hex decoded to 32 bytes and that public_key_fingerprint matched. It never checked that the bytes are a usable curve point, so a roster could enrol a small-order key. Ed25519 verification computes [-k]A + [S]B and compares the result to R. When A has small order that equation collapses: the signature R = identity, S = 0 verifies against every message. Anyone can then forge signatures for that identity without holding a private key, which voids every signature-based control for whichever role holds it. A coordinator who authors participants.json could plant such a key for a public witness and manufacture the receipts that exist to detect coordinator equivocation. Validate now rejects three cases: bytes that are not a curve point, non-canonical encodings, and points of small order. The canonical check matters on its own because identity uniqueness across the definition is enforced on public_key_fingerprint, a hash of these exact bytes, so two encodings of one point would otherwise register as two distinct identities. The guard lives in Identity.Validate, so it also covers the roles enrolled outside the ceremony definition: EnrollmentRecord, PublicWitnessReceipt and ImmutableMirrorReceipt each validate their embedded Identity. --- internal/mpcceremony/identity_key_test.go | 86 +++++++++++++++++++++++ internal/mpcceremony/model.go | 43 ++++++++++++ 2 files changed, 129 insertions(+) create mode 100644 internal/mpcceremony/identity_key_test.go diff --git a/internal/mpcceremony/identity_key_test.go b/internal/mpcceremony/identity_key_test.go new file mode 100644 index 0000000..0d33d80 --- /dev/null +++ b/internal/mpcceremony/identity_key_test.go @@ -0,0 +1,86 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "strings" + "testing" +) + +// smallOrderEd25519Keys are the canonical encodings of the eight points of +// order dividing 8 on edwards25519, plus the two non-canonical encodings of the +// small-order points that decode successfully. Any of them, enrolled as an +// identity, makes signatures under that identity forgeable without a private +// key. +var smallOrderEd25519Keys = []string{ + "0100000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", +} + +func TestIdentityRejectsSmallOrderPublicKeys(t *testing.T) { + for _, keyHex := range smallOrderEd25519Keys { + raw, err := hex.DecodeString(keyHex) + if err != nil { + t.Fatalf("decode %s: %v", keyHex, err) + } + if _, err := NewIdentity("participant-01", "Participant One", "participant-key", raw); err == nil { + t.Fatalf("NewIdentity accepted small-order public key %s", keyHex) + } + } +} + +// TestSmallOrderPublicKeyAcceptsForgedSignature documents why the check above +// exists. Without it, this signature verifies against the identity point for +// any message at all. +func TestSmallOrderPublicKeyAcceptsForgedSignature(t *testing.T) { + identityPoint := make([]byte, ed25519.PublicKeySize) + identityPoint[0] = 0x01 + + forged := make([]byte, ed25519.SignatureSize) + forged[0] = 0x01 // R = identity encoding, S = 0 + + for _, message := range []string{"one message", "an entirely different message"} { + if !ed25519.Verify(identityPoint, []byte(message), forged) { + t.Fatalf("expected the forged signature to verify for %q; the premise of the guard no longer holds", message) + } + } + + if err := validateEd25519PublicKey(identityPoint); err == nil { + t.Fatal("validateEd25519PublicKey accepted the identity point") + } +} + +func TestIdentityRejectsOffCurvePublicKey(t *testing.T) { + // High bit of the final byte is the sign of x; the remaining field element + // is not a valid y coordinate for any curve point. + raw, err := hex.DecodeString("0200000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Fatalf("decode: %v", err) + } + err = validateEd25519PublicKey(raw) + if err == nil { + t.Fatal("validateEd25519PublicKey accepted an off-curve encoding") + } + if !strings.Contains(err.Error(), "curve point") { + t.Fatalf("unexpected error %v", err) + } +} + +func TestIdentityAcceptsGeneratedKey(t *testing.T) { + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate key: %v", err) + } + if err := validateEd25519PublicKey(public); err != nil { + t.Fatalf("validateEd25519PublicKey rejected a freshly generated key: %v", err) + } + if _, err := NewIdentity("participant-01", "Participant One", "participant-key", public); err != nil { + t.Fatalf("NewIdentity rejected a freshly generated key: %v", err) + } +} diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index 3a21165..fb0fb24 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -11,8 +11,10 @@ import ( "path" "strings" "time" + "unicode" "unicode/utf8" + "filippo.io/edwards25519" "golang.org/x/crypto/blake2b" ) @@ -143,6 +145,9 @@ func (i Identity) Validate() error { if err != nil { return fmt.Errorf("identity ed25519_public_key_hex: %w", err) } + if err := validateEd25519PublicKey(pub); err != nil { + return fmt.Errorf("identity ed25519_public_key_hex: %w", err) + } want := taggedSHA256(pub) if i.PublicKeyFingerprint != want { return fmt.Errorf("identity public_key_fingerprint %q, want %q", i.PublicKeyFingerprint, want) @@ -500,6 +505,34 @@ func scanJSONValue(decoder *json.Decoder) error { return nil } +// validateEd25519PublicKey rejects the byte strings that decode without error +// but are unusable as a ceremony identity. +// +// Ed25519 verification computes [-k]A + [S]B and compares it to R. When A is a +// small-order point that equation collapses: the signature R = identity, S = 0 +// then verifies against every message, so anyone can forge signatures for that +// identity without holding a private key. Enrolling such a key in the roster +// therefore voids every signature-based control for that participant, auditor, +// witness, coordinator, or release signer. +// +// Non-canonical encodings are rejected separately. Identity uniqueness across +// the definition is enforced on public_key_fingerprint, which is a hash of +// these exact bytes, so two encodings of one point would otherwise present as +// two distinct identities. +func validateEd25519PublicKey(pub []byte) error { + point, err := new(edwards25519.Point).SetBytes(pub) + if err != nil { + return fmt.Errorf("not a valid Ed25519 curve point: %w", err) + } + if !bytes.Equal(point.Bytes(), pub) { + return errors.New("Ed25519 public key is not canonically encoded") + } + if new(edwards25519.Point).MultByCofactor(point).Equal(edwards25519.NewIdentityPoint()) == 1 { + return errors.New("Ed25519 public key has small order; signatures under it are forgeable") + } + return nil +} + func validateTaggedHex(value, prefix string, bytes int) error { if !strings.HasPrefix(value, prefix) { return fmt.Errorf("must start with %q", prefix) @@ -547,6 +580,16 @@ func validateArtifactName(value string) error { if strings.Contains(value, "\\") || strings.HasPrefix(value, "/") || path.Clean(value) != value || value == "." { return fmt.Errorf("artifact name %q must be a clean relative logical path", value) } + for _, r := range value { + if unicode.IsControl(r) { + return fmt.Errorf("artifact name %q contains a control character", value) + } + } + for segment := range strings.SplitSeq(value, "/") { + if segment != strings.TrimSpace(segment) { + return fmt.Errorf("artifact name %q has untrimmed whitespace in a path segment", value) + } + } return nil } From 096bfd85c87da99f4a865ffe41b5c38c05613db8 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Fri, 14 Aug 2026 05:36:03 +0000 Subject: [PATCH 02/42] Accept more than two audits in a production decision Three layers disagreed on how many audits a ceremony may have. A definition may enrol two or more auditors (definition.go). SignRelease accepts two or more signed passing reports (audit.go). ProductionDecision demanded exactly two. A ceremony that enrolled three auditors, which is permitted and strictly more conservative, could therefore produce a valid signed release that could never be recorded in a valid decision. The failure surfaces at final GO signing, after the ceremony is complete and nothing can be redone. Both audit lists now require at least two rather than exactly two, and every site that consumed them follows. - Validate: distinctness of auditor key ids and external signer fingerprints moves from comparing elements 0 and 1 to a set check across the whole slice. - requiredDecisionSigners: every named auditor must sign, not just the first two. An auditor whose report is bound into the decision but whose consent is not required would otherwise be recorded as having reviewed the release without agreeing to it, and a signature from them was rejected as falling outside the required threshold. - verifyProductionRelease: expectedAuditRefs is built from the full slice, so a release binding three audits coheres with its final transcript, which already accepted two or more. - allLocatedArtifacts: every audit's record and signature enters URI conflict detection and the located-artifact digest sweep. releaseChecksumNames and verifyReleaseTreeExact were already count-derived. --- internal/mpcceremony/decision.go | 64 ++++++++++++++++++++------------ 1 file changed, 40 insertions(+), 24 deletions(-) diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index d6c4025..6644037 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -484,9 +484,16 @@ func (d ProductionDecision) Validate() error { if err := d.OperationalEvidence.Validate(); err != nil { return fmt.Errorf("operational_evidence: %w", err) } - if len(d.Audits) != 2 { - return fmt.Errorf("production decision requires exactly two audits, got %d", len(d.Audits)) - } + // Two is the floor, not the ceiling. A ceremony may enroll more than two + // auditors (definition.go requires at least two), and SignRelease accepts + // every passing report it is given. Demanding exactly two here would let a + // three-auditor ceremony produce a valid signed release that could never be + // recorded in a valid decision, and the failure would only surface at final + // GO signing when nothing can be redone. + if len(d.Audits) < 2 { + return fmt.Errorf("production decision requires at least two audits, got %d", len(d.Audits)) + } + auditKeyIDs := make(map[string]struct{}, len(d.Audits)) for index, audit := range d.Audits { if err := audit.Validate(); err != nil { return fmt.Errorf("audit %d: %w", index, err) @@ -494,13 +501,15 @@ func (d ProductionDecision) Validate() error { if index > 0 && audit.AuditorID <= d.Audits[index-1].AuditorID { return errors.New("audits must be ordered by distinct auditor_id") } + if _, duplicate := auditKeyIDs[audit.AuditorKeyID]; duplicate { + return errors.New("production audit key ids must be distinct") + } + auditKeyIDs[audit.AuditorKeyID] = struct{}{} } - if d.Audits[0].AuditorKeyID == d.Audits[1].AuditorKeyID { - return errors.New("production audit key ids must be distinct") - } - if len(d.ExternalAudits) != 2 { - return fmt.Errorf("production decision requires exactly two external audits, got %d", len(d.ExternalAudits)) + if len(d.ExternalAudits) < 2 { + return fmt.Errorf("production decision requires at least two external audits, got %d", len(d.ExternalAudits)) } + externalFingerprints := make(map[string]struct{}, len(d.ExternalAudits)) for index, external := range d.ExternalAudits { if err := external.Validate(); err != nil { return fmt.Errorf("external audit %d: %w", index, err) @@ -508,10 +517,10 @@ func (d ProductionDecision) Validate() error { if index > 0 && external.Auditor.ID <= d.ExternalAudits[index-1].Auditor.ID { return errors.New("external audits must be ordered by distinct auditor identity") } - } - if d.ExternalAudits[0].Auditor.PublicKeyFingerprint == - d.ExternalAudits[1].Auditor.PublicKeyFingerprint { - return errors.New("external audit signer keys must be distinct") + if _, duplicate := externalFingerprints[external.Auditor.PublicKeyFingerprint]; duplicate { + return errors.New("external audit signer keys must be distinct") + } + externalFingerprints[external.Auditor.PublicKeyFingerprint] = struct{}{} } if err := d.K21Rehearsal.Validate(); err != nil { return err @@ -940,9 +949,12 @@ func verifyDecisionRelease(definition CeremonyDefinition, decision ProductionDec if err := UnmarshalCanonical(transcriptBytes, &transcript); err != nil { return fmt.Errorf("final transcript: %w", err) } - expectedAuditRefs := []ArtifactRef{ - releaseLogicalArtifact(releaseDirName, decision.Audits[0].Audit.Record.Artifact), - releaseLogicalArtifact(releaseDirName, decision.Audits[1].Audit.Record.Artifact), + expectedAuditRefs := make([]ArtifactRef, 0, len(decision.Audits)) + for _, audit := range decision.Audits { + expectedAuditRefs = append( + expectedAuditRefs, + releaseLogicalArtifact(releaseDirName, audit.Audit.Record.Artifact), + ) } expectedOperationalRefs := SignedArtifactRefs{ Record: releaseLogicalArtifact( @@ -1250,13 +1262,18 @@ func decisionSignerIdentity( } } +// requiredDecisionSigners lists every signature a GO decision must carry. +// Every named auditor is required, not just the first two: the decision accepts +// two or more audits, and an auditor whose report is bound into the decision but +// whose consent is not required would be recorded as having reviewed the release +// without having agreed to it. func requiredDecisionSigners(definition CeremonyDefinition, decision ProductionDecision) []string { - return []string{ - string(DecisionSignerCoordinator) + "\x00" + definition.Coordinator.ID, - string(DecisionSignerAuditor) + "\x00" + decision.Audits[0].AuditorID, - string(DecisionSignerAuditor) + "\x00" + decision.Audits[1].AuditorID, - string(DecisionSignerRelease) + "\x00" + definition.ReleaseSigner.ID, + required := make([]string, 0, len(decision.Audits)+2) + required = append(required, string(DecisionSignerCoordinator)+"\x00"+definition.Coordinator.ID) + for _, audit := range decision.Audits { + required = append(required, string(DecisionSignerAuditor)+"\x00"+audit.AuditorID) } + return append(required, string(DecisionSignerRelease)+"\x00"+definition.ReleaseSigner.ID) } func validateLocatedArtifactCoherence(decision ProductionDecision) error { @@ -1286,14 +1303,13 @@ func allLocatedArtifacts(decision ProductionDecision) []LocatedArtifactRef { decision.SourceRelease.SignedTagObject, decision.OperationalEvidence.Record, decision.OperationalEvidence.Signature, - decision.Audits[0].Audit.Record, - decision.Audits[0].Audit.Signature, - decision.Audits[1].Audit.Record, - decision.Audits[1].Audit.Signature, decision.K21Rehearsal.Evidence, decision.MainnetDeploymentPlan, decision.FormalChecklist, } + for _, audit := range decision.Audits { + refs = append(refs, audit.Audit.Record, audit.Audit.Signature) + } refs = append(refs, decision.Release.Artifacts...) for _, external := range decision.ExternalAudits { refs = append(refs, external.Report, external.Signoff) From 2e90bea48f3491fc3f42cbbe2a9cd6e00f8b3995 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 13 Aug 2026 08:47:45 +0000 Subject: [PATCH 03/42] Report chain replay progress on stderr A K=21 phase close replays every accepted contribution before it writes anything, which runs for hours and produced no output. An operator could not tell a running replay from a hung one, and could not measure how long a close takes on their hardware. That measurement is not a convenience. The closure commits to a future drand round, and choosing a round far enough ahead requires knowing how long the replay will take. Misjudging it is what caused the 2026-07-24 closure-timing incident. The current code fails loudly in that case rather than publishing an invalid closure, but the operator still burns the attempt with no better information for the retry. internal/mpcceremony deliberately has no logger: it handles signing keys and secret contribution state, and having no output path is stronger than having a careful one. A callback preserves that. ReplayProgress carries a phase, a one-based index and a total, never a path or key material, and rendering is the caller's business. PhaseTranscriptPaths carries the optional callback, which reaches every replay site already threaded through that struct. The CLI writes to stderr, never stdout, which is reserved for the result contract. Single head loads pass nil because they read one record rather than replaying. --- cmd/mpc-ceremony/executor.go | 17 ++++++++ .../direct_acceptance_boundary_test.go | 2 + internal/mpcceremony/workflow.go | 42 ++++++++++++++++--- 3 files changed, 55 insertions(+), 6 deletions(-) diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index f190194..d22e5bd 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -672,6 +672,23 @@ func transcriptPaths(root, chain, signature string) mpcceremony.PhaseTranscriptP RootDir: root, ChainPath: chain, ChainSignaturePath: signature, + Progress: replayProgressReporter(), + } +} + +// replayProgressReporter renders replay progress to stderr. A K=21 replay runs +// for hours; without this an operator cannot tell running from hung, and cannot +// measure how long a close takes in order to choose a beacon round far enough +// ahead. Output goes to stderr because stdout carries the result contract, and +// it reports only a phase, an index and a count — never a path or key material. +func replayProgressReporter() mpcceremony.ReplayProgress { + start := time.Now() + return func(phase mpcceremony.Phase, index, total int) { + fmt.Fprintf( + os.Stderr, + "replaying %s contribution %d/%d (%s elapsed)\n", + phase, index, total, time.Since(start).Round(time.Second), + ) } } diff --git a/internal/mpcceremony/direct_acceptance_boundary_test.go b/internal/mpcceremony/direct_acceptance_boundary_test.go index f684a68..cfcf817 100644 --- a/internal/mpcceremony/direct_acceptance_boundary_test.go +++ b/internal/mpcceremony/direct_acceptance_boundary_test.go @@ -44,6 +44,7 @@ func TestCoordinatorDirectTransitionProtocolBoundaries(t *testing.T) { fixture.ceremonyRoot, chain, fixture.circuit.Binding.DomainSize, + nil, )(0) if err != nil { t.Fatalf("load authenticated Phase 1 head: %v", err) @@ -95,6 +96,7 @@ func TestCoordinatorDirectTransitionProtocolBoundaries(t *testing.T) { fixture.ceremonyRoot, chain, contributionPhase2Shape(fixture.circuit.Binding.Phase2Shape), + nil, )(0) if err != nil { t.Fatalf("load authenticated Phase 2 head: %v", err) diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 72e071a..f2b8e44 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -372,10 +372,32 @@ func InitializeCeremonyFiles(options InitFilesOptions) (result InitFilesResult, return result, nil } +// ReplayProgress reports how far a chain replay has advanced. It is called once +// per accepted contribution, immediately before that contribution is read, with +// a one-based index and the total the replay will process. +// +// This package deliberately has no logger: it handles signing keys and secret +// contribution state, so having no output path at all is stronger than having a +// careful one. A callback keeps that property. The values carry no secret +// material — a phase, an index and a count — and rendering is entirely the +// caller's business. The CLI writes them to stderr, never stdout, which is +// reserved for the result contract. +// +// A K=21 close replays for hours. Without progress an operator cannot tell +// running from hung, and cannot measure how long a close takes on their +// hardware. That measurement is what makes it possible to choose a beacon round +// far enough ahead; misjudging it is what caused the 2026-07-24 closure-timing +// incident. +type ReplayProgress func(phase Phase, index, total int) + type PhaseTranscriptPaths struct { RootDir string ChainPath string ChainSignaturePath string + + // Progress is optional. When nil the replay is silent, which is the + // behaviour every existing caller gets. + Progress ReplayProgress } // LoadSignedChain verifies the exact coordinator-signed chain at paths. @@ -465,7 +487,7 @@ func loadReplayPhase1FilesState( if err != nil { return Chain{}, nil, err } - loader := phase1FileLoader(paths.RootDir, chain, circuit.Binding.DomainSize) + loader := phase1FileLoader(paths.RootDir, chain, circuit.Binding.DomainSize, paths.Progress) head, err := replayPhase1State(circuit.Binding.DomainSize, len(chain.Records), loader) if err != nil { return Chain{}, nil, err @@ -553,7 +575,7 @@ func LoadReplayPhase2Files( if err != nil { return Chain{}, err } - loader := phase2FileLoader(paths.RootDir, chain, contributionPhase2Shape(circuit.Binding.Phase2Shape)) + loader := phase2FileLoader(paths.RootDir, chain, contributionPhase2Shape(circuit.Binding.Phase2Shape), paths.Progress) if err := ReplayPhase2Loaded(circuit, commons, len(chain.Records), loader); err != nil { return Chain{}, err } @@ -625,7 +647,7 @@ func CreateContributionCandidate(options ContributionFilesOptions) (result Contr contribution, contributeErr := ContributePhase1Loaded( options.Circuit.Binding.DomainSize, len(chain.Records), - phase1FileLoader(options.Transcript.RootDir, chain, options.Circuit.Binding.DomainSize), + phase1FileLoader(options.Transcript.RootDir, chain, options.Circuit.Binding.DomainSize, options.Transcript.Progress), ) if contributeErr != nil { return nil, contributeErr @@ -649,7 +671,7 @@ func CreateContributionCandidate(options ContributionFilesOptions) (result Contr options.Circuit, commons, len(chain.Records), - phase2FileLoader(options.Transcript.RootDir, chain, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape)), + phase2FileLoader(options.Transcript.RootDir, chain, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape), options.Transcript.Progress), ) if contributeErr != nil { return nil, contributeErr @@ -1011,6 +1033,7 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result options.Transcript.RootDir, chain, options.Circuit.Binding.DomainSize, + nil, )(index - 2) } if err != nil { @@ -1038,6 +1061,7 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result options.Transcript.RootDir, chain, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape), + nil, )(index - 2) } if err != nil { @@ -2827,11 +2851,14 @@ func verifyChainFiles(trusted *TrustedCeremony, root string, chain Chain, basePh return nil } -func phase1FileLoader(root string, chain Chain, domainN uint64) Phase1Loader { +func phase1FileLoader(root string, chain Chain, domainN uint64, progress ReplayProgress) Phase1Loader { return func(index int) (*gnarkmpc.Phase1, error) { if index < 0 || index >= len(chain.Records) { return nil, fmt.Errorf("Phase 1 contribution index %d out of range", index) } + if progress != nil { + progress(Phase1, index+1, len(chain.Records)) + } path, err := resolveArtifactPath(root, chain.Records[index].OutputPayload.Name) if err != nil { return nil, err @@ -2841,11 +2868,14 @@ func phase1FileLoader(root string, chain Chain, domainN uint64) Phase1Loader { } } -func phase2FileLoader(root string, chain Chain, shape Phase2Shape) Phase2Loader { +func phase2FileLoader(root string, chain Chain, shape Phase2Shape, progress ReplayProgress) Phase2Loader { return func(index int) (*gnarkmpc.Phase2, error) { if index < 0 || index >= len(chain.Records) { return nil, fmt.Errorf("Phase 2 contribution index %d out of range", index) } + if progress != nil { + progress(Phase2, index+1, len(chain.Records)) + } path, err := resolveArtifactPath(root, chain.Records[index].OutputPayload.Name) if err != nil { return nil, err From bc0ab28644b18361c378adc347c369b58f0067ef Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 13 Aug 2026 08:47:54 +0000 Subject: [PATCH 04/42] Add audit change list and a local ceremony runbook Records the findings behind the preceding commits, plus the items that are not code changes, so a reviewer can see what was checked and what was left open. Each entry cites the file and line that establishes it, and separates verified findings from proposals and from items that were named but not investigated. The local runbook documents how to build the tool and stand up a ceremony on one machine. It is orientation and rehearsal only. The production procedure is docs/mpc-ceremony-runbook.md, which is absent from main and survives only in refs/pull/34/head of the upstream repository (item B1). It also records the two roots of trust, the coordinator public key and the binary, which must arrive over channels the reader already trusts. scripts/mpc-demo-init.sh runs the documented init end to end. It builds with go build rather than go run, because go run omits the VCS metadata that software.go requires, and it reads the coordinator key id back from participants.json rather than hardcoding it. --- docs/mpc-ceremony-local-runbook.md | 257 +++++++++++++++++++ docs/mpc-ceremony-proposed-changes.md | 340 ++++++++++++++++++++++++++ scripts/mpc-demo-init.sh | 61 +++++ 3 files changed, 658 insertions(+) create mode 100644 docs/mpc-ceremony-local-runbook.md create mode 100644 docs/mpc-ceremony-proposed-changes.md create mode 100755 scripts/mpc-demo-init.sh diff --git a/docs/mpc-ceremony-local-runbook.md b/docs/mpc-ceremony-local-runbook.md new file mode 100644 index 0000000..8c49d31 --- /dev/null +++ b/docs/mpc-ceremony-local-runbook.md @@ -0,0 +1,257 @@ +# MPC Ceremony — Local Runbook + +Everything below was executed against the working tree at `ba065e6` and the +outputs are the real ones, not illustrative. + +## Scope + +This is an orientation and rehearsal runbook: how to build the tool, stand up a +ceremony on one machine, and read what comes out. It is **not** a production +procedure. + +The production procedure is `docs/mpc-ceremony-runbook.md` (1,590 lines), which +is currently absent from `main` — see `mpc-ceremony-proposed-changes.md` item B1. +It survives in `refs/pull/34/head` of `Anastasia-Labs/proof-tool` at commit +`fd8516e`. Anything about enrollment, custody, witnessing, mirrors, beacon +selection, or release gates comes from that document, not this one. + +Same-host identities prove nothing about participant independence. A rehearsal +transcript is never mainnet key material. + +## The two roots of trust + +Every other file in a ceremony is derived and self-authenticating. Exactly two +things must reach you through channels you already trust. + +**1. The coordinator public key.** `coordinator-public-key.hex` decides whether a +signature counts. Take it from the same bundle as the signature it verifies and +you have proven only that the bundle agrees with itself — which any forger can +arrange. It must arrive over an independent authenticated channel. + +**2. The binary.** `SoftwareBinding` in the definition pins the tool digest, +source commit, and dependency versions; `VerifyRunningSoftware` refuses to +proceed on a mismatch. So the binary is a trust input too: built from a verified +signed tag, reproduced in two independent environments, hashes published +separately. `scripts/build-mpc-ceremony-release.sh` and +`scripts/verify-mpc-ceremony-reproducible.sh` do this for production. + +Everything else — `ceremony.json`, `ceremony.sig`, chains, contributions, +closures — may travel over untrusted transport. Tampering makes verification +fail rather than succeed. + +## Trust paths + +Nearly every subcommand takes the same three flags, which map to +`mpcceremony.TrustPaths` (`internal/mpcceremony/workflow.go:46`): + + --ceremony ceremony.json + --ceremony-signature ceremony.sig + --coordinator-public-key-file coordinator-public-key.hex + +All three are mandatory (`workflow.go:180-184`). `LoadSignedDefinition` turns +them into a `TrustedCeremony`, and every downstream check validates against that +rather than against loose files. The third path exists specifically so the trust +anchor is supplied from outside the bundle. The code cannot tell whether you +honoured that; only your process can. + +## Prerequisites + +Go 1.26.5 exactly, per `go.mod` and the pinned `ProductionGoVersion` in +`internal/mpcceremony/model.go`. A user-local install is fine: + + export PATH="$HOME/.local/go/bin:$PATH" + go version # go1.26.5 linux/amd64 + +**Build with `go build`, never `go run`.** `go run` does not embed VCS metadata, +and the binary refuses to start without it: + + running executable is missing vcs build setting + +`software.go:172-205` requires `vcs`, `vcs.revision` and `vcs.modified`. +`vcs.revision` becomes the ceremony's `source_commit`, which every contribution +attestation must match; `vcs.modified` must be `false` for production, so a +dirty checkout is refused outright. Inspect any binary with +`go version -m ./dist/mpc-ceremony`. + +## Quick start + + bash scripts/mpc-demo-init.sh /tmp/mpcdemo 3 + +That wrapper does the three steps below and refuses to reuse an existing root. +The manual form follows, because the wrapper hides the parts worth understanding. + +### 1. Build + + go build -o dist/mpc-ceremony ./cmd/mpc-ceremony + ./dist/mpc-ceremony help + +### 2. Generate rehearsal identities and canonical config + + go run ./scripts/mpc-rehearsal-config --out-dir /tmp/mpcdemo --participants 3 + +Writes `config/{participants,policy,environment}.json` plus Ed25519 keypairs for +eleven identities at three participants: coordinator, release signer, two +auditors, three participants, two public witnesses, two mirror operators. + +These config files are **canonical JSON**, not ordinary JSON. The decoder rejects +unknown fields, duplicate fields, reordered fields, pretty printing, extra +whitespace, trailing data, and a trailing newline. Do not hand-edit them and do +not round-trip them through `jq -S`; alphabetical key sorting changes the schema +order and the file stops parsing. Generate them with a program that calls +`MarshalCanonical`. + +### 3. Initialize + + D=/tmp/mpcdemo + ./dist/mpc-ceremony --format json init \ + --key-version ownership-destination-v2 \ + --participants "$D/config/participants.json" \ + --policy "$D/config/policy.json" \ + --coordinator-key-id coordinator-key \ + --coordinator-signing-key "$D/keys/coordinator.ed25519.private.hex" \ + --created-at 2026-08-11T00:00:00Z \ + --mode rehearsal \ + --out-dir "$D/public" + +`--coordinator-key-id` must equal the `key_id` inside `participants.json`. It is +not a name you choose. Read it back rather than guessing: + + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["coordinator"]["key_id"])' \ + "$D/config/participants.json" + +Expect several minutes; `init` compiles the K=21 circuit. Observed output: + + {"level":"info","message":"compiling circuit"} + {"nbSecret":157,"nbPublic":1,"message":"parsed circuit inputs"} + {"nbConstraints":1791413,"message":"building constraint builder"} + {"schema":"proof-tool-mpc-command-result-v1","ok":true,"command":"init", + "ceremony_id":"sha256:965b04d8...520e", ... } + +## The seven artifacts + + 4608 ceremony.json + 434 ceremony.sig + 65 coordinator-public-key.hex + 129448055 ownership-destination.ccs + 490 phase1/chain-0000.json + 434 phase1/chain-0000.sig + 603980121 phase1/genesis.bin + +**`ceremony.json`** — the signed root document. Its `ceremony_id` is a +domain-tagged SHA-256 over its own canonical bytes, so the file names itself. +Contains the circuit binding (1,791,413 constraints, domain 2,097,152 = 2^21, the +R1CS digest), the pinned software stack, the roster, per-phase policies, and the +drand beacon policy. Also a `session_nonce_hex` so two ceremonies with identical +inputs still receive distinct IDs. + +**`ceremony.sig`** — detached Ed25519 signature over the exact bytes of +`ceremony.json`. Carries `signed_sha256`, so the signature names what it covers, +plus `key_id` and `public_key_fingerprint`. + +**`coordinator-public-key.hex`** — the raw 32-byte public key in hex. Trust root; +distribute out of band. + +**`ownership-destination.ccs`** — the compiled constraint system. Makes the +ceremony circuit-specific: Phase 2 is built from it, and its digest is pinned in +`ceremony.json`, so a different circuit is a different ceremony. + +**`phase1/genesis.bin`** — the starting powers-of-tau state, 576 MiB. The first +432 bytes are three empty update proofs (tau, alpha, beta); the real ladder +begins at offset 432 with a length prefix of `0x200000` = 2,097,152. Points are +compressed, and `0xc0` in a leading byte means "compressed, point at infinity". + +**`phase1/chain-0000.json`** — the empty chain: `"records": []`, plus `phase_id` +and the genesis `ArtifactRef` pinning that 576 MiB file by both digests and its +size. This is the head the first participant contributes on top of. + +**`phase1/chain-0000.sig`** — coordinator signature over that chain document. + +Note the split: two files hold all 705 MB of data, five hold all the authority in +about 6 KB. The large files are inert until a signed record names them by digest. + +## Verifying what you got + +The signature names its own key and its own payload. Both bindings should check +out: + + python3 - <<'EOF' + import hashlib, json + D = "/tmp/mpcdemo/public" + pk = open(f"{D}/coordinator-public-key.hex").read().strip() + sig = json.load(open(f"{D}/ceremony.sig")) + print("key fingerprint :", "sha256:" + hashlib.sha256(bytes.fromhex(pk)).hexdigest()) + print("claimed in sig :", sig["public_key_fingerprint"]) + print("signed_sha256 :", sig["signed_sha256"]) + print("actual of json :", "sha256:" + hashlib.sha256(open(f"{D}/ceremony.json","rb").read()).hexdigest()) + EOF + +This proves internal consistency only. It becomes meaningful when the public key +came from an independent channel. + +## Gotchas encountered + +- `go run` fails with `missing vcs build setting`. Use `go build`. +- `--coordinator-key-id` must match `participants.json`. A wrong value produces + a redacted error that blanks your input but leaves the correct value visible, + because that came from a file rather than argv. +- `scripts/mpc-demo-init.sh` refuses an existing root. Use a fresh path. +- The full rehearsal harness refuses to start below its capacity floors — 100 GiB + free and 16 GiB available RAM by default. Check with + `scripts/check-mpc-k21-capacity.sh`, override via `MPC_K21_MIN_*` env vars. +- Config files are canonical JSON. Editing them by hand breaks parsing. + +## Beyond init + +The next step is `phase1 contribute` for the first scheduled participant, which +replays the entire accepted chain before sampling entropy. At K=21 with three +participants that is gigabytes of I/O and hours of verification, with **no +progress output** — see `mpc-ceremony-proposed-changes.md` item A3. + +For a staged, resumable local run through the whole lifecycle, use the real +harness instead of driving the CLI by hand: + + scripts/run-mpc-k21-local-rehearsal.sh prepare "$FRESH_ROOT" ./dist/mpc-ceremony 5 + scripts/run-mpc-k21-local-rehearsal.sh phase1-contribute "$FRESH_ROOT" ./dist/mpc-ceremony + scripts/run-mpc-k21-local-rehearsal.sh phase1-close "$FRESH_ROOT" ./dist/mpc-ceremony FUTURE_ROUND + ... + +It never fetches a beacon. The operator closes each phase on a future drand +round, publicly witnesses the closure, waits for that round, obtains the exact +raw response independently, and resumes. That sequencing is the security +property, not a formality: see the 2026-07-24 closure-timing incident recorded in +`docs/mpc-production-readiness.md`. + +## Beacon precedent in other ceremonies + +How the drand-quicknet-with-future-round design compares to other trusted-setup +implementations (surveyed 2026-08-11): + +- **Celo snark-setup-operator (Plumo)** — yes, drand mainnet, pre-announced + future round (923709, ~June 8 2021). `verify_transcript --apply-beacon` seeds + an RNG from the 32-byte beacon hash, runs an actual contribution, then + re-verifies it against the transcript + ([verify_transcript.rs](https://github.com/celo-org/snark-setup-operator/blob/master/src/bin/verify_transcript.rs), + [celo-bls-snark-rs #220](https://github.com/celo-org/celo-bls-snark-rs/issues/220)). + Mechanically the closest precedent to this design. +- **Perpetual Powers of Tau** — yes, applied per phase-2 branch-off rather than + once: announce a future Ethereum beacon-chain slot, take its RANDAO reveal, + apply via `snarkjs powersoftau beacon … 31` (2^31 hash iterations) + ([prepare-phase-2.md](https://github.com/privacy-ethereum/perpetualpowersoftau/blob/master/prepare-phase-2.md)). + The doc itself notes "experts differ as to whether the beacon step adds any + security" but snarkjs requires it. +- **p0tion (PSE)** — yes at finalization, but weakest: the coordinator types a + beacon value into a prompt, which is SHA-256'd and applied via `zKey.beacon` + with only 2^10 iterations; no drand, block hash, or future-round binding + anywhere in the repo + ([finalize.ts](https://github.com/privacy-ethereum/p0tion/blob/main/packages/phase2cli/src/commands/finalize.ts), + [prompts.ts:705](https://github.com/privacy-ethereum/p0tion/blob/main/packages/phase2cli/src/lib/prompts.ts)). + +The pattern comes from Zcash's 2018 Powers of Tau — 2^42 SHA-256 iterations over +the hash of Bitcoin block 514200, pre-announced +([attestation 0088](https://github.com/ZcashFoundation/powersoftau-attestations/tree/master/0088)). +The "beacon is unnecessary" claim traces to the Snarky Ceremonies paper +([eprint 2021/219](https://eprint.iacr.org/2021/219.pdf), +Kohlweiss/Maller/Siim/Volkhov, Asiacrypt 2021), which proved Groth16 ceremony +security without a beacon — yet all three implementations above still apply one +as defense-in-depth. This project's drand-quicknet-with-future-round design is +in line with the field and stricter than p0tion, roughly matching Plumo. diff --git a/docs/mpc-ceremony-proposed-changes.md b/docs/mpc-ceremony-proposed-changes.md new file mode 100644 index 0000000..0bb2256 --- /dev/null +++ b/docs/mpc-ceremony-proposed-changes.md @@ -0,0 +1,340 @@ +# MPC Ceremony — Proposed Changes + +Checked against the working tree at `ba065e6` on 2026-08-10. Items marked +**verified** cite the file and line that establishes them. Items marked +**proposal** are new work, not defects. Items marked **open** were not +investigated and are listed so they are not mistaken for cleared. + +No cryptographic break was found. Severity below reflects operational impact. + +## A · Consistency defects + +Both are fail-closed — they block valid work rather than admit invalid work — +but both surface at the worst possible moment. + +### A1 · Audit count is inconsistent across three layers — medium, verified + +A ceremony may enroll **two or more** auditors (`internal/mpcceremony/definition.go:164`). +`SignRelease` accepts **two or more** signed audit reports +(`internal/mpcceremony/audit.go:867`, `len(inputs) < 2`). But `ProductionDecision` +requires **exactly two** (`internal/mpcceremony/decision.go:487`, `len(d.Audits) != 2`). + +A ceremony that enrolls three auditors — permitted, and strictly more +conservative — can therefore produce a valid signed release that can never be +recorded in a valid production decision. The failure appears after the ceremony +is complete, at final GO signing, when nothing can be redone. + +**Fix.** Pick one rule and apply it in all three places. Accepting `>= 2` in the +decision is the better direction: more independent auditors should never be +harder to record than the minimum. The same question applies to `ExternalAudits` +at `internal/mpcceremony/decision.go:501`. + +### A2 · The runbook's failover drill calls a command that does not exist — medium, verified + +Step 3 of the Restore And Failover Drill instructs the operator to "run read-only +`inspect`, and compare the derived next participant/index with the primary run +card." There is no `inspect` in the CLI — neither `cmd/mpc-ceremony/parse.go` nor +`cmd/mpc-ceremony/usage.go` mentions it. + +The only `inspect` is a stage of `scripts/run-mpc-k21-local-rehearsal.sh:1616`, +and it reads that script's own `state/steps/*.complete` markers rather than the +signed chain. A production ceremony driven through the CLI directly — which is +what the runbook's main body documents — has no recovery inspection at all. + +The answer is a pure function of already-signed data: + + next_index = len(chain.Records) + 1 + next_participant = policy.Participants[len(chain.Records)] + +with the frozen order enforced at `internal/mpcceremony/chain.go:283-286`. No +signing key and no replay are required. + +**Fix.** Add `mpc-ceremony inspect` — read-only, public keys only, never writes. +Report ceremony ID and mode, per-phase accepted count and head record ID, next +scheduled participant and index, and which artifacts are present or missing. Two +verification depths: metadata-and-hashes by default (seconds), full replay behind +`--full` (hours at K=21). It must state which depth it ran; during a recovery +window nobody waits for the replay. + +### A3 · Long-running commands report no progress — medium, verified + +`internal/mpcceremony` has no logger and no print path at all. That is the right +call for this domain: the package handles signing keys and secret contribution +state, and having no output path is stronger than having a careful one. It also +keeps operations deterministic and replayable with no side channels. The CLI +reinforces it by redirecting gnark's global logger to stderr so stdout carries +only the result contract (`cmd/mpc-ceremony/main.go:20-23`). + +The cost is that a K=21 phase close replays for hours with zero output. An +operator cannot distinguish running from hung, and cannot calibrate how long a +close actually takes on their hardware. + +That is not merely a usability complaint. Misjudging replay duration is precisely +what caused the 2026-07-24 closure-timing incident: the operator chose a beacon +round roughly an hour out, the replay took longer than that, and the round was +already public by the time the closure was written. The current code fails +loudly in that situation (see the `validateCloseCommitTime` guard), so the unsafe +closure can no longer be produced — but the operator still burns the attempt and +must restart with a farther round, having no better information than last time +about how far is far enough. + +**Fix.** Add progress reporting that does not weaken the boundary. Two options +that both preserve the no-print rule inside the package: + +- an optional progress callback on the `*Options` structs, invoked per replayed + contribution with an index and count, which the CLI renders to **stderr**; or +- structured timing returned in the `*Result` struct, so the CLI can report + measured per-contribution and total replay duration after the fact. + +The callback form is more useful operationally because it also feeds the +beacon-round choice: an operator who can see "contribution 3 of 5, 41 minutes +elapsed" can pick a safe round. Neither form prints from the package, and neither +carries secret material — an index, a count, and a duration only. + +### A4 · CLI error redaction is a per-call-site blocklist — low, verified + +Before printing an error, the CLI runs the message through `redactCLIError` +(`cmd/mpc-ceremony/main.go:137-167`), which collects argv-derived strings, sorts +them longest-first, and `strings.ReplaceAll`s them out. The intent is right: +arguments include signing-key paths. Three limits are worth recording. + +1. **It only catches what literally appears in argv.** A path read from a config + file, or any value derived from a key, is not in the candidate set and passes + through unmodified. +2. **It is opt-in per call site.** `writeDiagnostic` (`main.go:227`) performs no + redaction; only the error paths call `redactCLIError`. A new diagnostic that + forgets it leaks silently, and nothing in the build catches that. +3. **The candidate guard is minimal.** `addCLIErrorCandidate` rejects only `""`, + `"-"` and `"--"` (`main.go:220-225`), so a short argument value can blank + unrelated substrings of a message. That is over-redaction rather than a leak, + but it degrades diagnostics exactly when they are needed. + +This is defense-in-depth, not the actual control. The real protection is that +`internal/mpcceremony` has no print path at all, so secret material is never in a +position to be written. Redaction is the net under that. + +**Fix.** Low priority, but two cheap hardening steps: route *all* CLI output +through one helper that redacts by construction, so a new call site cannot opt +out by accident; and add a minimum-length floor in `addCLIErrorCandidate` to stop +short values blanking unrelated text. Neither changes the trust boundary. + +## B · Documentation integrity + +### B1 · Eight governance documents were stripped from `main`; ten links to them remain — high, verified + +PR #34 merged, but the branch was history-filtered and force-pushed first. +Diffing the pull-request head against the merged head yields exactly eight +deleted documentation files, 2,738 lines, and **zero code changes**. Both +lineages have 217 commits with byte-identical author and committer timestamps — +the signature of a path-filtering rewrite, not a revert. + + docs/mpc-ceremony-runbook.md 1590 + docs/mpc-external-audit-package.md 202 + docs/mpc-production-readiness.md 198 + docs/mpc-security-review.md 192 + docs/mpc-production-go-no-go-template.md 187 + docs/production-readiness.md 143 + docs/next-steps-to-mainnet.md 124 + docs/mainnet-deployment-preparation.md 102 + +No commit deletes them; they survive only in `refs/pull/34/head` (`fd8516e`) of +`https://github.com/Anastasia-Labs/proof-tool`. Meanwhile `docs/README.md` still +indexes five of them with full descriptions — including "the formal mainnet +go/no-go matrix, current **NO-GO**, blocking rehearsal incident" — and +`docs/trusted-setup-ceremony.md` links three more. Ten dangling references in +total. + +The practical effect: `main` advertises a NO-GO decision record it does not +contain, and the procedure governing a mainnet trusted setup exists only inside a +pull-request ref. + +**Fix.** Ask upstream whether the removal was deliberate before restoring +anything — documents that say NO-GO and disqualify the current binary may have +been withheld on purpose. If deliberate, remove the ten dangling links so the +index stops advertising absent files. If accidental, restore all eight. The +current state is the worst of both. + +## C · New capability: object-storage backend (S3/R2) + +Proposal, not a defect. The governing rule is one sentence: **object storage is +transport, never trust.** + +### C1 · Keep all fetching outside the ceremony binary + +`internal/mpcceremony` imports no networking at all, deliberately. The runbook's +guarantee boundary lists "no implicit `latest`, overwrite, or network-fetch +behavior" as an enforced property, and `internal/mpcceremony/decision.go:84` +states that verification "never fetches a URI or trusts mutable network state." +Putting fetch inside the binary deletes a stated security property. + +**Design.** A separate sync tool moves bytes; the ceremony tool keeps verifying +local files. Downloading is already safe because every artifact is pinned by +digest in the signed chain and re-checked by `verifyArtifactBytes` — a hostile +bucket can cause a failure, never a forgery. + +### C2 · Closure publication has no atomic equivalent in object storage — highest risk of this section + +On-disk safety rests on `RENAME_NOREPLACE` and staged directories published by +atomic rename. S3/R2 has no atomic directory rename. Per-object create-if-absent +is available via conditional writes (`If-None-Match: *`), but a closure directory +can become **half-visible** — and the closure is precisely the artifact whose +publication moment is security-critical, since the 2026-07-24 incident was a +closure-timing failure. + +**Design.** Upload closure objects under a temporary prefix, then make them +visible by writing a single immutable pointer object last. One object flip, not a +multi-object window. + +### C3 · A mirror is only immutable if the bucket enforces it + +Anyone holding credentials can overwrite an object. To honestly claim an +`ImmutableMirrorReceipt`, the bucket needs object lock, retention, and +versioning — and the receipt should record that configuration alongside +`StorageLocationSHA256`. + +Independence is a separate requirement: two buckets in one R2 account is one +mirror. The gate wants distinct operators, exactly as the three-relay beacon rule +does. + +### C4 · Reuse the existing publication allowlist; emit real mirror receipts + +`scripts/package-mpc-public-evidence.sh` already builds a "fail-closed, +content-hashed public evidence tree" where "private control keys and files +outside the explicit allowlist are never copied." Do not write a second answer to +*what may be published* — that is how a signing key reaches a bucket. + +On the other side, the sync tool should emit `ImmutableMirrorReceipt` records +(`internal/mpcceremony/operational.go:341`) from its uploads. Those feed the +operational evidence bundle and satisfy the two-independent-mirrors gate, so the +work lands in a slot the schema already has. + +- Verify by re-downloading and re-hashing, not by trusting the upload response. +- A `latest` pointer is for humans; no tool may resolve one. +- Sizing is comfortable: roughly 3.6 GB of accepted state at five participants + and roughly 9 GB of cumulative prefix downloads. R2 zero-egress matters because + each participant pulls the full prefix before contributing. + +## D · Open — not yet investigated + +### D1 · The verifying-key seam between ceremony and deployed validator + +The ceremony's entire output is a verifying key that +`contracts/ownership-verifier` consumes — 785 lines in `src/Ownership/Verify.hs` +doing on-chain BLS12-381 Groth16, parsing the VK from a `BuiltinByteString`. A +flawless ceremony plus a validator that misparses or misapplies that VK still +loses funds; a perfect validator fed a compromised VK verifies forgeries happily. +Neither audit covers the seam. + +Start from `scripts/verify-mpc-final-plutus-evidence.sh` and +`internal/mpcceremony/plutus_evidence_script_test.go` — they exist specifically to +test this seam, so they record what the authors already believed needed proving. + +**Partially traced, and there is a gap.** The VK reaches the chain as a +compile-time script parameter. `reclaim-scripts-export global-v2` takes +`<672-byte-cardano-verifier-key-hex>` *and* +`` as two separate arguments +(`contracts/ownership-verifier/export/ReclaimDeploymentScripts.hs:79`). +`printGlobalV2Script` then prints that hash straight into the exported JSON's +`verifier_vk_hash` field without ever hashing the VK bytes it compiled in +(`ReclaimDeploymentScripts.hs:91-95`). The exporter will therefore emit a script +that verifies against VK *A* while its manifest advertises `blake2b256(B)`. + +Whether a downstream check binds them — `verify-proof-release.mjs`, the +reclaim-server manifest code, or the coherence checks the runbook lists — is not +yet traced. The current Preprod manifest +(`apps/ownership-proof-web/public/proof-assets/reclaim-deployment.json`) is +self-consistent, with `reclaim_global.verifier_vk_hash` equal to +`proof.cardano_vk_blake2b256`, and is honestly labelled +`destination_key_provenance: "single-actor local Preprod setup; not an MPC +ceremony"`. + +This is the same failure shape as the snarkjs/Circom incidents documented by +zkSecurity (Foom, ~$1.4M; Veil, 2.9 ETH): correct library, correct maths, wrong +artifact deployed. + +### D2 · Subgroup checks are disabled on the streaming proving-key path + +BLS12-381's curves have cofactors, so points on `E(F_p)` outside the order-`r` +subgroup exist. Accepting one as a group element is the classic small-subgroup / +invalid-curve failure (Cremers and Jackson, *Prime, Order Please!*, CSF 2019). + +The ceremony path is closed. gnark-crypto's `NewDecoder` defaults +`subGroupCheck: true` (`ecc/bls12-381/marshal.go:63`), mpcsetup's `ReadFrom` uses +that default, and `UpdateProof.Verify` additionally runs explicit +`IsInSubGroup()` on both proof points and rejects the infinity point +(`ecc/bls12-381/mpcsetup/mpcsetup.go:94-99`). + +Six call sites outside the ceremony explicitly opt out: + + internal/streampk/keysource.go:116,133,378,393 + internal/msmengine/serialize.go:112,148 + +All pass `curve.NoSubgroupChecks()`. Both callers were traced. They differ. + +**`msmengine` is authenticated — no issue found.** The chunked browser path +verifies every chunk before any decoder sees it +(`apps/ownership-proof-web/public/proof-runtime/msm-worker.js:317-326`): exact +size, `content-encoding: identity` enforced, then `__msmengineVerifyChunkBytes` +against both the `sha256` and `blake2b256` recorded in the signed +`ChunkManifest`, with verify-before-cache so rejected bytes cannot enter the LRU. +The unchecked decoder is reached only via `unmarshalG1PointsPinned` / +`unmarshalG2PointsPinned`, whose doc comment states they decode +"digest-authenticated proving-key points", and which still run `IsOnCurve()` on +every point after skipping the subgroup check. The checked sibling +`unmarshalG1Points` uses `SetBytes`, which validates subgroups. + +One fragility worth fixing anyway: `pinnedDecode` defaults to `true` +(`cmd/wasm-prover/main_js.go:934`) and is overridable from request JSON +(`req.PinnedDecode`). It is a tuning knob, not a value derived from whether the +bytes were actually verified. Safe today only because the fetch path always +verifies; nothing enforces the coupling. + +**`streampk` is NOT authenticated on the URL path — this is the real finding.** +`internal/streampk` contains no digest verification at all: grepping +`range.go`, `keysource.go` and `index.go` for sha256/blake2b/digest/verify +returns nothing. `ValidateIndex` validates structure, not content. + +Its two callers diverge: + +- `openStreamingArtifactsFromDir` (`cmd/wasm-prover/main_js.go:1332-1357`) + verifies the proving key's SHA-256, BLAKE2b-256 **and** size against the signed + key manifest before calling `streampk.OpenKeyFile`. Correct. +- `openStreamingArtifactsFromURLs` (`main_js.go:1360-1441`) verifies the + verifying key thoroughly (hash, sha256, size) and compares the index's + `file_size` to the manifest — but **never digests the proving key bytes**. It + then calls `streampk.OpenKeyURL(&index, pkURL, opts...)`, which issues HTTP + range requests straight into decoders that skip subgroup checks. The key + manifest signature is itself optional on this path + (`verifyOptionalKeyManifestSignature`). + +The exposure is immediate rather than theoretical: `KeySource.open` +(`internal/streampk/keysource.go:112-139`) range-reads the G1 singletons +(alpha, beta, delta) and the G2 singletons (beta, delta) and decodes all five +with `NoSubgroupChecks()` at open time — before any chunk-manifest machinery +applies, and with no on-curve check either, unlike the `msmengine` pinned path. + +This is exactly the primitive the ZKHack trusted-setup puzzle exploits: a point +that parses, lies on the curve, and sits outside the order-r subgroup leaks the +secret scalar to Pohlig-Hellman over the smooth cofactor. BLS12-381's G1 +cofactor `(x-1)^2/3` factors into 3, 11, 10177, 859267 and 52437899, so the +smooth part is trivially attackable. What is at risk here is a proving key rather +than a ceremony secret, so the impact is malformed-input handling and possible +incorrect proofs rather than direct key recovery — but the missing check is the +same one. + +**Fix.** Either verify the proving key digest on the URL path before opening the +source, or have `streampk` verify per-range digests from a pinned index the way +the chunk path does. At minimum, add `IsOnCurve()` after the singleton decode so +`streampk` is no weaker than `msmengine`, and make `pinnedDecode` derive from +verification state rather than being caller-supplied. + +**Still open.** Whether `openStreamingArtifactsFromURLs` is reachable in a +production deployment, or whether shipping configurations always route through +the chunk-manifest path. That determines severity, not whether the gap exists. + +### D3 · Sweep the remaining twelve gates for the A1 defect class + +A1 was found by comparing what the definition permits, what the release accepts, +and what the decision demands for one gate. The other twelve were not checked for +the same mismatch — witnesses, mirrors, relay operators, and participant counts +all have counts asserted in more than one layer. diff --git a/scripts/mpc-demo-init.sh b/scripts/mpc-demo-init.sh new file mode 100755 index 0000000..b1d64d2 --- /dev/null +++ b/scripts/mpc-demo-init.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Builds the CLI and runs a rehearsal `init` end to end into a fresh root. +# +# Rehearsal only. It generates same-host identities and keys, which are not +# production enrollment and prove nothing about participant independence. +# Never point this at a production ceremony root. +# +# usage: scripts/mpc-demo-init.sh [ROOT] [PARTICIPANTS] +set -euo pipefail +umask 077 + +ROOT=${1:-/tmp/mpcdemo} +PARTICIPANTS=${2:-3} +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) + +# go build embeds vcs.revision and vcs.modified; `go run` does not, and the +# binary refuses to start without them (internal/mpcceremony/software.go). +export PATH="$HOME/.local/go/bin:$PATH" +command -v go >/dev/null || { echo "go not found on PATH" >&2; exit 1; } + +[ -e "$ROOT" ] && { echo "refusing to reuse existing root: $ROOT" >&2; exit 1; } + +BIN="$ROOT/bin/mpc-ceremony" +mkdir -p "$ROOT/bin" + +echo "==> building CLI" +(cd "$REPO_ROOT" && go build -o "$BIN" ./cmd/mpc-ceremony) + +echo "==> generating rehearsal identities and canonical config" +(cd "$REPO_ROOT" && go run ./scripts/mpc-rehearsal-config \ + --out-dir "$ROOT/config-root" \ + --participants "$PARTICIPANTS") + +CONFIG="$ROOT/config-root/config" +KEYS="$ROOT/config-root/keys" + +# The key id must match participants.json, not an invented name. Read it back +# rather than hardcoding it. +COORDINATOR_KEY_ID=$( + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["coordinator"]["key_id"])' \ + "$CONFIG/participants.json" +) +echo "==> coordinator key id: $COORDINATOR_KEY_ID" + +# Signed claim, so use an observed UTC time rather than a fabricated one. +CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +echo "==> init (compiles the K=21 circuit; expect several minutes)" +MPC_CEREMONY_DEBUG=${MPC_CEREMONY_DEBUG:-} "$BIN" --format json init \ + --key-version ownership-destination-v2 \ + --participants "$CONFIG/participants.json" \ + --policy "$CONFIG/policy.json" \ + --coordinator-key-id "$COORDINATOR_KEY_ID" \ + --coordinator-signing-key "$KEYS/coordinator.ed25519.private.hex" \ + --created-at "$CREATED_AT" \ + --mode rehearsal \ + --out-dir "$ROOT/public" + +echo +echo "==> artifacts" +find "$ROOT/public" -type f -printf '%10s %p\n' | sort -k2 From a37c27f5e0da568be8f3a7ebb4c49d5430fdfa8a Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 13 Aug 2026 09:38:41 +0000 Subject: [PATCH 05/42] Reject untrimmed whitespace in attested string fields ContributionEnvironment.OS/.Architecture and audit findings used a plain `== ""` presence check, so a single space satisfied "must not be empty" and flowed into signed attestations and records. Require the trimmed, non-empty form, matching the convention already used for Identity.DisplayName and (in e9a789f) artifact names. --- internal/mpcceremony/attestation.go | 6 ++++-- internal/mpcceremony/chain.go | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/mpcceremony/attestation.go b/internal/mpcceremony/attestation.go index f153380..2725242 100644 --- a/internal/mpcceremony/attestation.go +++ b/internal/mpcceremony/attestation.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "strings" "time" ) @@ -142,8 +143,9 @@ type ContributionEnvironment struct { } func (e ContributionEnvironment) Validate() error { - if e.OS == "" || e.Architecture == "" { - return errors.New("contribution environment OS and architecture are required") + if strings.TrimSpace(e.OS) == "" || e.OS != strings.TrimSpace(e.OS) || + strings.TrimSpace(e.Architecture) == "" || e.Architecture != strings.TrimSpace(e.Architecture) { + return errors.New("contribution environment OS and architecture must be non-empty and trimmed") } if e.EntropySource != "operating-system-csprng" { return fmt.Errorf("entropy_source %q, want operating-system-csprng", e.EntropySource) diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index 4ed520f..e12e4a3 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -9,6 +9,7 @@ import ( "hash" "math" "slices" + "strings" "time" ) @@ -1035,7 +1036,7 @@ func (r AuditRecord) validate(requireID bool) error { return errors.New("failed audit must contain at least one finding") } for _, finding := range r.Findings { - if finding == "" { + if strings.TrimSpace(finding) == "" { return errors.New("audit findings must not be empty") } } From ea03eeed844e494428fdbc87b0ba6843fc07d47f Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 13 Aug 2026 08:47:31 +0000 Subject: [PATCH 06/42] Keep on-curve validation when skipping subgroup checks KeySource.open decodes the G1 singletons (alpha, beta, delta) and the G2 singletons (beta, delta) with NoSubgroupChecks. Skipping the subgroup check is a deliberate throughput trade on a proving key that callers are expected to digest-authenticate first, and internal/msmengine makes the same trade. The difference is that msmengine still runs IsOnCurve on every decoded point, and streampk ran no validation at all. That gap matters because OpenKeyURL reaches this code over HTTP range requests, and the URL caller in cmd/wasm-prover does not digest the proving key before opening it. A point that parses but is not on the curve therefore entered a multi-scalar multiplication unchallenged. IsOnCurve is cheap relative to the decode and is now applied to all five singletons. This does not close the missing digest verification on the URL path, which needs a separate change. --- internal/streampk/keysource.go | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/internal/streampk/keysource.go b/internal/streampk/keysource.go index 035455d..25bed80 100644 --- a/internal/streampk/keysource.go +++ b/internal/streampk/keysource.go @@ -123,6 +123,17 @@ func (ks *KeySource) loadSmallFields(config openConfig) error { if err := g1Decoder.Decode(&ks.delta); err != nil { return fmt.Errorf("decode Delta: %w", err) } + // Subgroup checks are skipped for throughput on a proving key that callers + // are expected to have digest-authenticated first. On-curve validation is + // cheap and is kept, so a point that is neither a valid curve point nor in + // the authenticated key cannot silently enter a multi-scalar + // multiplication. See internal/msmengine/serialize.go, which makes the same + // trade explicitly. + for name, point := range map[string]*curve.G1Affine{"Alpha": &ks.alpha, "Beta": &ks.beta, "Delta": &ks.delta} { + if !point.IsOnCurve() { + return fmt.Errorf("%s is not on the G1 curve", name) + } + } kSec := ks.idx.Sections["K"] g2Off := kSec.Offset + kSec.Len @@ -137,6 +148,11 @@ func (ks *KeySource) loadSmallFields(config openConfig) error { if err := g2Decoder.Decode(&ks.g2delta); err != nil { return fmt.Errorf("decode G2.Delta: %w", err) } + for name, point := range map[string]*curve.G2Affine{"G2.Beta": &ks.g2beta, "G2.Delta": &ks.g2delta} { + if !point.IsOnCurve() { + return fmt.Errorf("%s is not on the G2 curve", name) + } + } g2bSec := ks.idx.Sections["G2B"] infOff := g2bSec.Offset + g2bSec.Len From e9743f5d096c9a5765845bea8573c2a90e181739 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 13 Aug 2026 09:39:06 +0000 Subject: [PATCH 07/42] Bound untrusted decode paths against allocation DoS Four consumer paths decoded ceremony-derived artifacts before checking the length/count fields that drive allocation, letting a hostile or corrupt input exhaust memory or panic (unrecoverable throw on the native verifier, module abort on wasm): - prover.UnmarshalProof: preflight the BSB22 commitment-count prefix and the exact encoded length before gnark-crypto runs make([]G1Affine, count). Closes a remote unauthenticated OOM on the verifier HTTP API. - wasm-prover fetchCCS: bound the decoded CCS to its signed size, cap the zstd decoder window, and recover() the decode so a hostile length prefix errors instead of aborting the module. - proofassets.ValidatePKIndexAllocations: bound NbWires, NbInfinityA/B, and NbCommitmentKeys against the signed FileSize and section geometry. Applied on the full-index paths (ReadPKIndex, streampk.ValidateIndex); the manifest digest covers only geometry, so the counters were otherwise free. - streampk domain decode: validate the FFT cardinality is canonical before precomputing twiddles, so a hostile 2^32 cardinality is rejected before the ~274 GB allocation. Adds regression tests for the proof and PK-index paths. --- cmd/wasm-prover/main_js.go | 66 ++++++++++++++++++++++++---- internal/proofassets/pkindex.go | 60 +++++++++++++++++++++++++ internal/proofassets/pkindex_test.go | 66 ++++++++++++++++++++++++++++ internal/prover/prover.go | 42 ++++++++++++++++++ internal/prover/prover_test.go | 35 +++++++++++++++ internal/streampk/index.go | 5 ++- internal/streampk/keysource.go | 22 +++++++--- 7 files changed, 280 insertions(+), 16 deletions(-) create mode 100644 internal/proofassets/pkindex_test.go diff --git a/cmd/wasm-prover/main_js.go b/cmd/wasm-prover/main_js.go index 45cbd3f..2f6502f 100644 --- a/cmd/wasm-prover/main_js.go +++ b/cmd/wasm-prover/main_js.go @@ -1009,7 +1009,15 @@ func openConstraintSystem(req artifactRequest, manifest *artifact.KeyManifest, c if expectedCCSAsset != nil && expectedCCSAsset.Compressed != nil && expectedCCSAsset.Compressed.Encoding == "zstd" { compressedPin = expectedCCSAsset.Compressed } - ccs, digest, encoding, err := fetchCCSPreferCompressed(ccsURL, compressedPin) + // The decoded CCS size is pinned by the signed manifest whenever an asset + // pin is present; use it to bound the decoder's reads (and, for the + // compressed variant, the zstd inflate) so a hostile or corrupt object + // cannot stream unbounded bytes. Without a pin, fall back to a coarse cap. + maxDecoded := int64(maxCCSDecodedBytes) + if expectedCCSAsset != nil && expectedCCSAsset.Size > 0 { + maxDecoded = expectedCCSAsset.Size + } + ccs, digest, encoding, err := fetchCCSPreferCompressed(ccsURL, compressedPin, maxDecoded) if err != nil { return nil, err } @@ -1054,13 +1062,42 @@ type ccsLoadStats struct { var lastCCSLoadStats ccsLoadStats +// maxCCSDecodedBytes bounds the decoded constraint system when no signed size +// pin is available. The ownership CCS is ~129 MiB; 2 GiB is a generous ceiling +// that still fits a wasm32 address space and rejects an unbounded stream. +const maxCCSDecodedBytes = 1 << 31 + +// zstdMaxMemory returns a decoder window-memory ceiling proportional to the +// expected decoded size, clamped to a sane floor so small objects still +// decode. The decoder never needs more window than the object it produces. +func zstdMaxMemory(maxDecoded int64) uint64 { + const floor = 1 << 26 // 64 MiB + if maxDecoded < floor { + return floor + } + return uint64(maxDecoded) +} + +// safeCCSReadFrom decodes a constraint system, converting a decoder panic +// (e.g. make([]byte, totalLen) on a hostile length prefix) into an error so a +// malformed object cannot abort the wasm module. +func safeCCSReadFrom(ccs constraint.ConstraintSystem, r io.Reader) (err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("constraint system decode panicked: %v", rec) + } + }() + _, err = ccs.ReadFrom(r) + return err +} + // fetchCCSPreferCompressed fetches the CCS via its pinned zstd transport // variant when one is supplied, falling back to the identity URL when the // compressed object is unavailable. The returned digest is always over the // DECODED bytes, so the caller's checks against the identity pin are // unchanged. A compressed-digest mismatch fails closed — the pin is signed, // so wrong bytes are tamper evidence, not a transport hiccup. -func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedAssetPin) (constraint.ConstraintSystem, prover.FileDigest, string, error) { +func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedAssetPin, maxDecoded int64) (constraint.ConstraintSystem, prover.FileDigest, string, error) { if compressed != nil { // A query-carrying ccs_url (signed URL, cache buster) cannot yield a // valid sibling URL — the token belongs to the identity object — so @@ -1074,7 +1111,7 @@ func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedA if err != nil { return nil, prover.FileDigest{}, "", fmt.Errorf("resolve compressed ccs url: %w", err) } - ccs, digest, err := fetchCCS(compressedURL, compressed) + ccs, digest, err := fetchCCS(compressedURL, compressed, maxDecoded) if err == nil { return ccs, digest, "zstd", nil } @@ -1084,7 +1121,7 @@ func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedA } msmengine.EmitTrace("measure", "open-ccs-compressed-fallback", map[string]any{"error": err.Error()}) } - ccs, digest, err := fetchCCS(ccsURL, nil) + ccs, digest, err := fetchCCS(ccsURL, nil, maxDecoded) return ccs, digest, "identity", err } @@ -1115,7 +1152,7 @@ func (e *assetUnavailableError) Unwrap() error { return e.err } // frame: the wire bytes are hashed and length-checked against the pin while // the decoder inflates them, and the decoded stream is hashed for the // caller's identity-pin checks. -func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin) (constraint.ConstraintSystem, prover.FileDigest, error) { +func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin, maxDecoded int64) (constraint.ConstraintSystem, prover.FileDigest, error) { requestStarted := time.Now() resp, err := http.Get(rawURL) if err != nil { @@ -1146,7 +1183,10 @@ func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin) (constr return nil, prover.FileDigest{}, fmt.Errorf("create blake2b digest: %w", err) } wire = &countingReader{r: io.TeeReader(body, io.MultiWriter(wireSHA, wireBlake))} - zstdDecoder, err = zstd.NewReader(wire) + // Bound the decoder's window memory. klauspost's default is 64 GiB, so + // without this a tiny frame declaring a huge window is itself a memory + // bomb, independent of how much output we read. + zstdDecoder, err = zstd.NewReader(wire, zstd.WithDecoderMaxMemory(zstdMaxMemory(maxDecoded))) if err != nil { return nil, prover.FileDigest{}, fmt.Errorf("create zstd decoder: %w", err) } @@ -1155,12 +1195,22 @@ func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin) (constr } else { decoded = body } - reader := &countingReader{r: io.TeeReader(decoded, hashes)} + // Cap the decoded byte count at the pinned size (plus one, to detect + // overrun). gnark's CS decoder trusts an 8-byte length prefix and does + // make([]byte, totalLen) before reading; the limit stops an inflate bomb + // or a corrupt object from streaming unbounded bytes, and the recover + // boundary below turns an oversized make into an error instead of aborting + // the wasm module. + if maxDecoded < 1 { + maxDecoded = maxCCSDecodedBytes + } + limited := io.LimitReader(decoded, maxDecoded+1) + reader := &countingReader{r: io.TeeReader(limited, hashes)} ccs := groth16.NewCS(ecc.BLS12_381) decodeStarted := time.Now() bodyBefore, hashBefore := body.duration, hashes.duration - if _, err := ccs.ReadFrom(reader); err != nil { + if err := safeCCSReadFrom(ccs, reader); err != nil { err = fmt.Errorf("read constraint system: %w", err) if compressed != nil { // A truncated frame or mid-body reset on the compressed object is diff --git a/internal/proofassets/pkindex.go b/internal/proofassets/pkindex.go index 06dc711..cf000f5 100644 --- a/internal/proofassets/pkindex.go +++ b/internal/proofassets/pkindex.go @@ -165,6 +165,63 @@ func ValidatePKIndex(idx *PKIndex) error { return nil } +// ValidatePKIndexAllocations bounds the counter fields that drive memory +// allocation when a proving key is opened: make([]bool, NbWires) and +// make([]pedersen.ProvingKey, NbCommitmentKeys) in KeySource.loadSmallFields, +// and len(wires)-NbInfinityA in the prove path. It is separate from +// ValidatePKIndex because the manifest-derived index carries only section +// geometry (the counters live outside the signed digest); call this only where +// a full index with populated counters is consumed. Each counter is bounded +// against FileSize and Sections — fields the manifest digest does cover — so an +// out-of-range counter is unrepresentable without also changing a signed field. +// +// ValidatePKIndex must have passed first. +func ValidatePKIndexAllocations(idx *PKIndex) error { + if idx == nil { + return fmt.Errorf("index is required") + } + g2b, ok := idx.Sections["G2B"] + if !ok { + return fmt.Errorf("index missing section \"G2B\"") + } + // Layout after G2B: nbWires|NbInfinityA|NbInfinityB (3×8 bytes), then the + // two infinity bitmaps of NbWires bytes each, then the 4-byte commitment + // count. Everything must fit inside FileSize. + const infHeaderLen = 3 * 8 + infOff := g2b.Offset + g2b.Len + if idx.NbWires > math.MaxInt64/2 { + return fmt.Errorf("nb_wires %d is implausibly large", idx.NbWires) + } + bitmapEnd := infOff + infHeaderLen + 2*int64(idx.NbWires) + if bitmapEnd+4 > idx.FileSize { + return fmt.Errorf("nb_wires %d does not fit within file_size %d", idx.NbWires, idx.FileSize) + } + if idx.NbInfinityA > idx.NbWires || idx.NbInfinityB > idx.NbWires { + return fmt.Errorf("nb_infinity (%d, %d) exceeds nb_wires %d", idx.NbInfinityA, idx.NbInfinityB, idx.NbWires) + } + // Each commitment key contributes exactly two sections (Basis, + // BasisExpSigma) on top of the five base sections, so the count is bounded + // by the section map — itself bounded by the parsed input — and every + // referenced section must be present. + if 5+2*uint64(idx.NbCommitmentKeys) != uint64(len(idx.Sections)) { + return fmt.Errorf("nb_commitment_keys %d is inconsistent with %d sections", idx.NbCommitmentKeys, len(idx.Sections)) + } + for i := 0; i < int(idx.NbCommitmentKeys); i++ { + basisName, sigmaName := "Basis", "BasisExpSigma" + if i > 0 { + basisName = fmt.Sprintf("Basis_%d", i) + sigmaName = fmt.Sprintf("BasisExpSigma_%d", i) + } + if _, ok := idx.Sections[basisName]; !ok { + return fmt.Errorf("index missing commitment section %q", basisName) + } + if _, ok := idx.Sections[sigmaName]; !ok { + return fmt.Errorf("index missing commitment section %q", sigmaName) + } + } + return nil +} + func WritePKIndex(path string, idx *PKIndex) error { if err := ValidatePKIndex(idx); err != nil { return err @@ -192,6 +249,9 @@ func ReadPKIndex(path string) (*PKIndex, error) { if err := ValidatePKIndex(&idx); err != nil { return nil, err } + if err := ValidatePKIndexAllocations(&idx); err != nil { + return nil, err + } return &idx, nil } diff --git a/internal/proofassets/pkindex_test.go b/internal/proofassets/pkindex_test.go new file mode 100644 index 0000000..bbc0a66 --- /dev/null +++ b/internal/proofassets/pkindex_test.go @@ -0,0 +1,66 @@ +package proofassets + +import ( + "math" + "strings" + "testing" +) + +// validAllocIndex returns a PKIndex whose geometry and counters are mutually +// consistent, matching what BuildPKIndex produces for a one-commitment key. +func validAllocIndex() *PKIndex { + const g2bOff = 10_000 + sections := map[string]PKSection{ + "A": {Name: "A", Offset: 100, Len: G1RawBytes, ElemSize: G1RawBytes}, + "B": {Name: "B", Offset: 200, Len: G1RawBytes, ElemSize: G1RawBytes}, + "Z": {Name: "Z", Offset: 300, Len: G1RawBytes, ElemSize: G1RawBytes}, + "K": {Name: "K", Offset: 400, Len: G1RawBytes, ElemSize: G1RawBytes}, + "G2B": {Name: "G2B", Offset: g2bOff, Len: G2RawBytes, ElemSize: G2RawBytes}, + "Basis": {Name: "Basis", Offset: 20_000, Len: G1RawBytes, ElemSize: G1RawBytes}, + "BasisExpSigma": {Name: "BasisExpSigma", Offset: 21_000, Len: G1RawBytes, ElemSize: G1RawBytes}, + } + return &PKIndex{ + Sections: sections, + NbWires: 4, + NbInfinityA: 1, + NbInfinityB: 0, + NbCommitmentKeys: 1, + FileSize: 100_000, + } +} + +func TestValidatePKIndexAllocations(t *testing.T) { + if err := ValidatePKIndex(validAllocIndex()); err != nil { + t.Fatalf("geometry validation failed on valid index: %v", err) + } + if err := ValidatePKIndexAllocations(validAllocIndex()); err != nil { + t.Fatalf("allocation validation failed on valid index: %v", err) + } + + cases := []struct { + name string + mutate func(*PKIndex) + wantSub string + }{ + {"huge commitment count", func(i *PKIndex) { i.NbCommitmentKeys = 0xFFFFFFFF }, "nb_commitment_keys"}, + {"nbWires overflow", func(i *PKIndex) { i.NbWires = math.MaxUint64 }, "implausibly large"}, + {"nbWires exceeds file", func(i *PKIndex) { i.NbWires = 1 << 40 }, "does not fit"}, + {"infinity exceeds wires", func(i *PKIndex) { i.NbInfinityA = 5 }, "exceeds nb_wires"}, + {"missing basis section", func(i *PKIndex) { + i.NbCommitmentKeys = 2 + i.Sections["Basis_1"] = PKSection{Name: "Basis_1", Offset: 30_000, Len: G1RawBytes, ElemSize: G1RawBytes} + // count now claims 2 keys (9 sections needed) but only 8 present: + // the equality check fires before the per-key lookup. + }, "inconsistent"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + idx := validAllocIndex() + tc.mutate(idx) + err := ValidatePKIndexAllocations(idx) + if err == nil || !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("want error containing %q, got %v", tc.wantSub, err) + } + }) + } +} diff --git a/internal/prover/prover.go b/internal/prover/prover.go index 14d738b..10cc397 100644 --- a/internal/prover/prover.go +++ b/internal/prover/prover.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "encoding/base64" + "encoding/binary" "encoding/hex" "fmt" "hash" @@ -56,6 +57,24 @@ const ( PokOff = CmtOff + 2*g1Len ) +const ( + // maxProofCommitments bounds the BSB22 commitment slice declared in an + // encoded proof. The ownership circuits use a single commitment; the cap + // is generous so unrelated circuits still decode, while a hostile count is + // rejected before gnark-crypto allocates it. + maxProofCommitments = 16 + + // proofCommitmentCountOffset is the byte offset of the big-endian uint32 + // commitment count in a compressed groth16-BLS12-381 proof: it follows + // Ar(G1) | Bs(G2) | Krs(G1). See the gnark Proof.ReadFrom field order. + proofCommitmentCountOffset = CardanoProofLen // 2*g1Len + g2Len + + // maxEncodedProofBytes bounds the raw proof before decoding. A well-formed + // proof for this family is a few hundred bytes; the cap is a coarse first + // gate so an oversized body is rejected cheaply. + maxEncodedProofBytes = 4096 +) + type OwnershipBundle struct { Dir string Manifest *artifact.KeyManifest @@ -421,6 +440,29 @@ func UnmarshalProof(encoded string) (groth16.Proof, error) { if err != nil { return nil, fmt.Errorf("decode proof: %w", err) } + // Preflight the length-prefixed commitment slice before gnark-crypto's + // decoder reaches it. That decoder does make([]G1Affine, count) directly + // from an attacker-controlled uint32 (gnark-crypto ecc/bls12-381 marshal), + // so an unchecked count is a memory-exhaustion primitive on any caller + // that decodes untrusted proofs (e.g. the verifier HTTP API). Bounding the + // count and requiring the exact encoded length makes the decode allocate + // only what the bytes actually carry. + if len(raw) > maxEncodedProofBytes { + return nil, fmt.Errorf("proof is %d bytes, exceeds maximum %d", len(raw), maxEncodedProofBytes) + } + if len(raw) < proofCommitmentCountOffset+4 { + return nil, fmt.Errorf("proof is %d bytes, too short to be well-formed", len(raw)) + } + nbCommitments := binary.BigEndian.Uint32(raw[proofCommitmentCountOffset : proofCommitmentCountOffset+4]) + if nbCommitments > maxProofCommitments { + return nil, fmt.Errorf("proof declares %d commitments, exceeds maximum %d", nbCommitments, maxProofCommitments) + } + // Ar|Bs|Krs, then the 4-byte count, then nbCommitments compressed G1 + // points, then the compressed G1 proof-of-knowledge. + wantLen := proofCommitmentCountOffset + 4 + int(nbCommitments)*g1Len + g1Len + if len(raw) != wantLen { + return nil, fmt.Errorf("proof is %d bytes, want %d for %d commitments", len(raw), wantLen, nbCommitments) + } proof := groth16.NewProof(curve) if _, err := proof.ReadFrom(bytes.NewReader(raw)); err != nil { return nil, fmt.Errorf("read proof: %w", err) diff --git a/internal/prover/prover_test.go b/internal/prover/prover_test.go index c2b3d98..e4bf4f4 100644 --- a/internal/prover/prover_test.go +++ b/internal/prover/prover_test.go @@ -3,6 +3,7 @@ package prover import ( "bytes" "encoding/base64" + "encoding/binary" "encoding/hex" "os" "path/filepath" @@ -56,6 +57,40 @@ func TestSmallProofMarshalVerifyAndRejectsWrongPublicInput(t *testing.T) { } } +func TestUnmarshalProofRejectsHostileCommitmentCount(t *testing.T) { + // A proof whose commitment-count prefix is enormous would drive + // make([]G1Affine, count) in gnark-crypto's decoder — a memory-exhaustion + // primitive for any endpoint that decodes untrusted proofs. It must be + // rejected before ReadFrom is ever called. + raw := make([]byte, proofCommitmentCountOffset+4) + binary.BigEndian.PutUint32(raw[proofCommitmentCountOffset:], 0xFFFFFFFF) + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(raw)); err == nil || + !strings.Contains(err.Error(), "commitments") { + t.Fatalf("expected commitment-count rejection, got %v", err) + } + + // Oversized body rejected by the coarse gate. + big := make([]byte, maxEncodedProofBytes+1) + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(big)); err == nil || + !strings.Contains(err.Error(), "maximum") { + t.Fatalf("expected oversize rejection, got %v", err) + } + + // Too short to carry the count prefix. + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(make([]byte, 8))); err == nil || + !strings.Contains(err.Error(), "too short") { + t.Fatalf("expected too-short rejection, got %v", err) + } + + // Declared count is in range but the body length does not match it. + mismatch := make([]byte, proofCommitmentCountOffset+4) + binary.BigEndian.PutUint32(mismatch[proofCommitmentCountOffset:], 1) + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(mismatch)); err == nil || + !strings.Contains(err.Error(), "want") { + t.Fatalf("expected length-mismatch rejection, got %v", err) + } +} + func TestOwnershipProofRoundTripIntegration(t *testing.T) { if os.Getenv("PROOF_TOOL_RUN_FULL_PROOF") != "1" { t.Skip("set PROOF_TOOL_RUN_FULL_PROOF=1 to run the full ownership Groth16 proof") diff --git a/internal/streampk/index.go b/internal/streampk/index.go index a918ba3..8b7041b 100644 --- a/internal/streampk/index.go +++ b/internal/streampk/index.go @@ -16,5 +16,8 @@ func BuildIndex(path string) (*Index, error) { } func ValidateIndex(idx *Index) error { - return proofassets.ValidatePKIndex(idx) + if err := proofassets.ValidatePKIndex(idx); err != nil { + return err + } + return proofassets.ValidatePKIndexAllocations(idx) } diff --git a/internal/streampk/keysource.go b/internal/streampk/keysource.go index 25bed80..af31fd4 100644 --- a/internal/streampk/keysource.go +++ b/internal/streampk/keysource.go @@ -183,15 +183,16 @@ func decodeDomainHeader(header []byte, precompute bool) (fft.Domain, error) { if flag := header[DomainHeaderBytes-1]; flag > 1 { return fft.Domain{}, fmt.Errorf("decode domain: precompute flag byte %d is not canonical", flag) } + // Always decode without precompute first. fft.Domain.ReadFrom precomputes + // twiddle and coset tables (make([]fr.Element, Cardinality) ×2) the moment + // it reads Cardinality, before any validation — a hostile cardinality of + // 2^32 would allocate ~274 GB before being rejected. Decode the header, + // validate the cardinality is canonical (power of two with a real FFT + // generator, bounding it to the field's 2-adicity), and only then rebuild + // the precomputed tables if the caller asked for them. var domain fft.Domain reader := bytes.NewReader(header) - var err error - if precompute { - _, err = domain.ReadFrom(reader) - } else { - _, err = domain.ReadFromWithoutPrecompute(reader) - } - if err != nil { + if _, err := domain.ReadFromWithoutPrecompute(reader); err != nil { return fft.Domain{}, fmt.Errorf("decode domain: %w", err) } if reader.Len() != 0 { @@ -200,6 +201,13 @@ func decodeDomainHeader(header []byte, precompute bool) (fft.Domain, error) { if err := validateCanonicalDomain(&domain); err != nil { return fft.Domain{}, fmt.Errorf("decode domain: %w", err) } + if precompute { + precomputed := fft.NewDomain(domain.Cardinality) + if precomputed.Cardinality != domain.Cardinality { + return fft.Domain{}, fmt.Errorf("decode domain: precompute cardinality mismatch") + } + domain = *precomputed + } return domain, nil } From 44cf9f9a5a8e6473a2a8ec2ea3765fdaecd6cd88 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Fri, 14 Aug 2026 06:35:04 +0000 Subject: [PATCH 08/42] Clone before handing retained states to gnark gnark's mpcsetup APIs mutate their arguments, and this package's discipline is to streamClone before any call that does. Three sites did not follow it. VerifyAndAcceptContribution verified the candidate it retains. Phase1.Verify and Phase2.Verify write next.Challenge, and the same candidate pointer is re-serialized into the authoritative transcript further down the function. Today the write is value-identical because the challenge-equality guard runs first, so nothing is corrupted, but the archived object is handed to a mutating API and stays correct only by coincidence. Both arms now verify a throwaway clone. That clone costs a second copy of the contribution state for the duration of the verify: roughly 576 MiB at K=21 for Phase 1, and the circuit-dependent equivalent for Phase 2. Acceptance already holds the predecessor and the candidate simultaneously, so this raises the peak by one state rather than changing the order of magnitude. Paying it buys the guarantee that no gnark call ever receives a pointer the transcript depends on. sealReplayedPhase1Head returns commons that alias the head it consumed. Seal returns p.parameters by value, and those slice headers point at the head's backing arrays rather than at copies, so mutating or re-sealing the head afterwards would corrupt commons already returned to the caller. The doc comment now says so, and both callers that keep the head in scope past the seal drop their reference at the call site, which makes reuse structurally impossible rather than merely discouraged. Phase2.Seal retains evals.G1.CKK and evals.G1.VKK in the keys it produces. Comments at the seal call site and at replayPhase2State's return record that evaluations must stay per-call, since a cached or shared Phase2Evaluations would leave two key sets aliasing one set of commitment arrays. --- internal/mpcceremony/phase1.go | 6 ++++++ internal/mpcceremony/phase2.go | 8 ++++++++ internal/mpcceremony/workflow.go | 24 ++++++++++++++++++++++-- 3 files changed, 36 insertions(+), 2 deletions(-) diff --git a/internal/mpcceremony/phase1.go b/internal/mpcceremony/phase1.go index fc84bcf..9e1082d 100644 --- a/internal/mpcceremony/phase1.go +++ b/internal/mpcceremony/phase1.go @@ -116,6 +116,12 @@ func SealPhase1Loaded( // sealReplayedPhase1Head consumes a freshly replayed head. gnark's Seal // intentionally mutates that head, so callers must not retain or reuse it. +// +// The returned commons also aliases the head: Seal returns p.parameters by +// value, and those slice headers point at the head's backing arrays rather than +// at copies. Mutating or re-sealing the head after this call therefore corrupts +// the commons that was already handed to the caller. Callers must drop their +// reference to the head at the point of the seal. func sealReplayedPhase1Head( domainN uint64, beaconChallenge []byte, diff --git a/internal/mpcceremony/phase2.go b/internal/mpcceremony/phase2.go index 476b7c5..1546c81 100644 --- a/internal/mpcceremony/phase2.go +++ b/internal/mpcceremony/phase2.go @@ -198,6 +198,10 @@ func SealPhase2Loaded( return nil, nil, err } + // Seal does not copy the evaluations: the returned proving and verifying + // keys retain evals.G1.CKK and evals.G1.VKK directly. The evaluations must + // therefore stay per-call and must never be cached or shared between + // seals, or two key sets would alias one set of commitment arrays. var provingKey, verifyingKey any if err := runGnarkMutation("seal Phase 2", func() { provingKey, verifyingKey = head.Seal(commons, evaluations, append([]byte(nil), beaconChallenge...)) @@ -270,6 +274,10 @@ func replayPhase2State( } previous = next } + // The returned evaluations are freshly derived for this replay and must be + // treated that way. Seal retains their CKK and VKK slices in the keys it + // produces, so a cached or reused Phase2Evaluations would leave two key + // sets aliasing one set of commitment arrays. return previous, &evaluations, nil } diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index f2b8e44..8c20063 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -1039,10 +1039,18 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result if err != nil { return result, fmt.Errorf("load authenticated Phase 1 head: %w", err) } + // gnark's Verify writes next.Challenge, and this candidate is retained + // and re-serialized into the authoritative transcript below. Hand the + // verifier a throwaway clone so no gnark call ever holds the archived + // pointer. + verifyCandidate := new(gnarkmpc.Phase1) + if err := streamClone(candidate, verifyCandidate); err != nil { + return result, fmt.Errorf("clone Phase 1 candidate for verification: %w", err) + } if err := verifyPhase1Transition( options.Circuit.Binding.DomainSize, previous, - candidate, + verifyCandidate, ); err != nil { return result, fmt.Errorf("verify candidate Phase 1 transition: %w", err) } @@ -1067,7 +1075,13 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result if err != nil { return result, fmt.Errorf("load authenticated Phase 2 head: %w", err) } - if err := verifyPhase2Transition(previous, candidate); err != nil { + // Same hazard as Phase 1: Verify writes next.Challenge and this + // candidate is retained for the transcript, so verify a clone. + verifyCandidate := new(gnarkmpc.Phase2) + if err := streamClone(candidate, verifyCandidate); err != nil { + return result, fmt.Errorf("clone Phase 2 candidate for verification: %w", err) + } + if err := verifyPhase2Transition(previous, verifyCandidate); err != nil { return result, fmt.Errorf("verify candidate Phase 2 transition: %w", err) } } @@ -1835,6 +1849,9 @@ func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResu challenge, replayedHead, ) + // Seal spends the head and the returned commons aliases its backing + // arrays. Drop the reference here so a later reuse cannot compile. + replayedHead = nil if err != nil { return result, err } @@ -3100,6 +3117,9 @@ func loadPhase1CommonsForPhase2( challenge, replayedHead, ) + // Seal spends the head and the returned commons aliases its backing + // arrays. Drop the reference here so a later reuse cannot compile. + replayedHead = nil if err != nil { return nil, SealRecord{}, CloseRecord{}, fmt.Errorf( "derive Phase 1 commons from authenticated chain and beacon: %w", From 150abd896c447e087a7f0ed2c5739b21cb0c571a Mon Sep 17 00:00:00 2001 From: Jason Park Date: Sat, 15 Aug 2026 14:29:11 +0000 Subject: [PATCH 09/42] Reject characters that make a name render as other than its bytes Identity.Validate checked display_name only for trimming and UTF-8 validity, so there was no length bound and interior ANSI escapes, bidi overrides, and zero-width characters reached signed records, transcripts, and logs. validateArtifactName was hardened earlier but shared the same blind spot: it screens with unicode.IsControl, which reports Unicode category Cc, while every bidi and zero-width character is category Cf and passed through. Both validators now share rejectDeceptiveRunes, which rejects control characters, the bidi formatting set, and U+200B. validateDisplayName adds a 256-byte cap. The bidi and zero-width sets are listed explicitly instead of rejecting all of category Cf, because U+200C separates Persian and Indic letterforms and U+200D joins emoji sequences; a blanket ban would make legitimate names unwritable. A test asserts those stay accepted. Nothing here was forgeable. display_name is never read for a decision and identity is keyed on id, key id, and public key fingerprint. The target is the human review that the audit and release stages depend on: a value stored as U+202E followed by "ecila" displays as "alice", so a reviewer approves one string while the transcript records another. That is the Trojan Source technique applied to attested names rather than source code. --- internal/mpcceremony/deceptive_names_test.go | 146 +++++++++++++++++++ internal/mpcceremony/model.go | 81 ++++++++-- 2 files changed, 218 insertions(+), 9 deletions(-) create mode 100644 internal/mpcceremony/deceptive_names_test.go diff --git a/internal/mpcceremony/deceptive_names_test.go b/internal/mpcceremony/deceptive_names_test.go new file mode 100644 index 0000000..f9263a0 --- /dev/null +++ b/internal/mpcceremony/deceptive_names_test.go @@ -0,0 +1,146 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "strings" + "testing" +) + +// Built with string(rune(...)) rather than written literally: these characters +// are invisible, and two of them would reorder this source file in an editor. +var ( + rlo = string(rune(0x202E)) // right-to-left override + lro = string(rune(0x202D)) // left-to-right override + rli = string(rune(0x2067)) // right-to-left isolate + pdi = string(rune(0x2069)) // pop directional isolate + lrm = string(rune(0x200E)) // left-to-right mark + zwsp = string(rune(0x200B)) // zero-width space + zwnj = string(rune(0x200C)) // zero-width non-joiner, legitimate + zwj = string(rune(0x200D)) // zero-width joiner, legitimate + esc = string(rune(0x001B)) // ANSI escape introducer + bel = string(rune(0x0007)) // bell +) + +// identityWithDisplayName builds an otherwise valid identity so the only thing +// under test is the display name. +func identityWithDisplayName(t *testing.T, displayName string) Identity { + t.Helper() + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + return Identity{ + ID: "participant-01", + DisplayName: displayName, + KeyID: "participant-01-key", + Ed25519PublicKeyHex: hex.EncodeToString(public), + PublicKeyFingerprint: taggedSHA256(public), + } +} + +// TestDisplayNameRejectsDeceptiveRunes covers the characters that make a signed +// value render as something other than its bytes. None of these forge anything: +// the target is the human reading a transcript, and the audit and release steps +// depend on that reading being accurate. +func TestDisplayNameRejectsDeceptiveRunes(t *testing.T) { + cases := []struct { + name string + displayName string + wantErr string + }{ + // Stored bytes read "ecilA"; a terminal renders "Alice". + {"right-to-left override", rlo + "ecilA", "bidirectional formatting"}, + {"left-to-right override", "Alice" + lro, "bidirectional formatting"}, + {"right-to-left isolate", "Alice" + rli + "Chen", "bidirectional formatting"}, + {"pop directional isolate", "Alice" + pdi, "bidirectional formatting"}, + {"left-to-right mark", "Alice" + lrm + "Chen", "bidirectional formatting"}, + // Renders identically to a plain "Alice", so two roster entries become + // indistinguishable on screen. + {"zero width space", "Ali" + zwsp + "ce", "zero-width"}, + {"ansi escape", "Alice" + esc + "[2K", "control character"}, + {"bell", "Alice" + bel, "control character"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + err := identityWithDisplayName(t, testCase.displayName).Validate() + if err == nil { + t.Fatalf("display name %q was accepted", testCase.displayName) + } + if !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("error %q does not mention %q", err, testCase.wantErr) + } + }) + } +} + +// TestDisplayNameAcceptsLegitimateText guards against over-blocking. ZWNJ and +// ZWJ are category Cf like the rejected characters, but they carry meaning: +// U+200C separates Persian and Indic letterforms and U+200D joins emoji +// sequences. Rejecting all of category Cf would make these names unwritable. +func TestDisplayNameAcceptsLegitimateText(t *testing.T) { + for _, displayName := range []string{ + "Alice Chen", + "Alice Chen, ZK Security", + "Zoe Muller", + "田中太郎", + "مريم", + "می" + zwnj + "خواهم", + "\U0001F469" + zwj + "\U0001F4BB", + strings.Repeat("a", maxDisplayNameBytes), + } { + if err := identityWithDisplayName(t, displayName).Validate(); err != nil { + t.Fatalf("legitimate display name %q was rejected: %v", displayName, err) + } + } +} + +func TestDisplayNameBounds(t *testing.T) { + cases := []struct { + name string + displayName string + wantErr string + }{ + {"empty", "", "1 to 256 bytes"}, + {"too long", strings.Repeat("a", maxDisplayNameBytes+1), "1 to 256 bytes"}, + {"blank", " ", "must be trimmed"}, + {"untrimmed", " Alice ", "must be trimmed"}, + {"invalid utf8", "Alice\xff", "valid UTF-8"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + err := identityWithDisplayName(t, testCase.displayName).Validate() + if err == nil { + t.Fatalf("display name %q was accepted", testCase.displayName) + } + if !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("error %q does not mention %q", err, testCase.wantErr) + } + }) + } +} + +// TestArtifactNameRejectsDeceptiveRunes covers the other half of the same gap. +// Artifact names were already screened with unicode.IsControl, which reports +// category Cc only, so every bidi and zero-width character (category Cf) passed +// until rejectDeceptiveRunes was shared between the two validators. +func TestArtifactNameRejectsDeceptiveRunes(t *testing.T) { + for _, name := range []string{ + "phase1/" + rlo + "gnp.nib", + "phase1/chain" + zwsp + "-0001.json", + "phase1/" + rli + "chain.json", + } { + if err := validateArtifactName(name); err == nil { + t.Fatalf("artifact name %q was accepted", name) + } + } + for _, name := range []string{ + "phase1/chain-0001.json", + "phase1/beacon/record.json", + "ownership-destination.ccs", + } { + if err := validateArtifactName(name); err != nil { + t.Fatalf("legitimate artifact name %q was rejected: %v", name, err) + } + } +} diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index fb0fb24..b908030 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -132,11 +132,8 @@ func (i Identity) Validate() error { if err := validateID("identity id", i.ID); err != nil { return err } - if strings.TrimSpace(i.DisplayName) == "" || i.DisplayName != strings.TrimSpace(i.DisplayName) { - return errors.New("identity display_name must be non-empty and trimmed") - } - if !utf8.ValidString(i.DisplayName) { - return errors.New("identity display_name must be valid UTF-8") + if err := validateDisplayName(i.DisplayName); err != nil { + return fmt.Errorf("identity display_name: %w", err) } if err := validateID("identity key_id", i.KeyID); err != nil { return err @@ -580,10 +577,8 @@ func validateArtifactName(value string) error { if strings.Contains(value, "\\") || strings.HasPrefix(value, "/") || path.Clean(value) != value || value == "." { return fmt.Errorf("artifact name %q must be a clean relative logical path", value) } - for _, r := range value { - if unicode.IsControl(r) { - return fmt.Errorf("artifact name %q contains a control character", value) - } + if err := rejectDeceptiveRunes(value); err != nil { + return fmt.Errorf("artifact name %q %w", value, err) } for segment := range strings.SplitSeq(value, "/") { if segment != strings.TrimSpace(segment) { @@ -593,6 +588,74 @@ func validateArtifactName(value string) error { return nil } +// maxDisplayNameBytes bounds a human-readable label. It is generous for a name +// plus an affiliation and small enough that a roster stays readable; without a +// cap a single identity can inflate the signed definition and every log line +// that mentions it. +const maxDisplayNameBytes = 256 + +// validateDisplayName checks a human-readable label that is never used for a +// decision but is read by people reviewing a transcript. +// +// The ceremony's audit and release steps depend on humans reading these +// records, so a label must render as the bytes that were signed. Length and +// UTF-8 validity are not enough for that; see rejectDeceptiveRunes. +func validateDisplayName(value string) error { + if value == "" || len(value) > maxDisplayNameBytes { + return fmt.Errorf("must contain 1 to %d bytes", maxDisplayNameBytes) + } + if !utf8.ValidString(value) { + return errors.New("must be valid UTF-8") + } + if value != strings.TrimSpace(value) { + return errors.New("must be trimmed") + } + if strings.TrimSpace(value) == "" { + return errors.New("must not be blank") + } + return rejectDeceptiveRunes(value) +} + +// rejectDeceptiveRunes rejects characters that make a string render as +// something other than the bytes that were signed. +// +// Three classes, all invisible: +// +// - Control characters (Unicode Cc). ANSI escape sequences are terminal +// commands rather than text, so a value printed to a terminal can move the +// cursor and repaint what was already written. +// - Bidirectional formatting (U+202A-U+202E, U+2066-U+2069, U+200E, U+200F). +// These force rendering direction, so bytes stored as U+202E followed by +// "ecila" display as "alice". This is the Trojan Source technique, +// CVE-2021-42574. +// - Zero-width space (U+200B), which renders as nothing, so two values that +// differ in bytes can be indistinguishable on screen. +// +// unicode.IsControl is not sufficient on its own: it reports category Cc only, +// while every bidi and zero-width character above is category Cf. +// +// The bidi and zero-width sets are listed explicitly rather than rejecting all +// of category Cf, because U+200C (ZWNJ) is required for correct Persian and +// Indic text and U+200D (ZWJ) joins emoji sequences. Banning the whole category +// would make legitimate names unwritable. +func rejectDeceptiveRunes(value string) error { + for _, r := range value { + switch { + case unicode.IsControl(r): + return fmt.Errorf("contains control character %U", r) + // Written as escapes on purpose: these characters are invisible, and + // two of them would reorder this source file in an editor. + case r >= '\u202A' && r <= '\u202E', + r >= '\u2066' && r <= '\u2069', + r == '\u200E', r == '\u200F': + return fmt.Errorf("contains bidirectional formatting character %U", r) + case r == '\u200B': + return fmt.Errorf("contains zero-width character %U", r) + } + } + return nil +} + func validateTimestamp(label, value string) error { if value == "" || !strings.HasSuffix(value, "Z") { return fmt.Errorf("%s must be a UTC RFC3339 timestamp ending in Z", label) From 07dd99a6b623461f67241e966f2987f49865aba4 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Sat, 15 Aug 2026 14:29:18 +0000 Subject: [PATCH 10/42] Track the ceremony attack/defense inventory An inventory of the deliberate defenses in the ceremony code, each mapped to the attack it counters with a file:line citation, plus the gaps found during the audit and their current state. It was written against the tree rather than committed with it, so it has been sitting untracked. That also blocks a production ceremony: Go stamps vcs.modified from git status, which counts untracked files, and a production definition requires a clean checkout. --- docs/mpc-ceremony-security-defenses.md | 634 +++++++++++++++++++++++++ 1 file changed, 634 insertions(+) create mode 100644 docs/mpc-ceremony-security-defenses.md diff --git a/docs/mpc-ceremony-security-defenses.md b/docs/mpc-ceremony-security-defenses.md new file mode 100644 index 0000000..92a0784 --- /dev/null +++ b/docs/mpc-ceremony-security-defenses.md @@ -0,0 +1,634 @@ +# MPC Ceremony — Attack/Defense Inventory + +A survey of the deliberate security defenses implemented in the ceremony codebase +(`internal/mpcceremony`, `internal/streampk`, `internal/msmengine`, +`internal/keybundle`, `cmd/mpc-ceremony`, `cmd/wasm-prover`), each mapped to the +attack it counters, with code citations. Known gaps are listed at the end. + +Line numbers are as of the commit this document was written against; treat them +as anchors, not guarantees. + +## ELI5 + +The ceremony is a group of people taking turns stirring secret ingredients into +a shared pot, and the final recipe is only safe if at least one person's +ingredient stays secret and nobody swaps the pot when no one is looking. Almost +every defense below is one of these five ideas: + +1. **Never trust a label, always check the contents.** Every file, key, and + record carries a fingerprint (hash), and the code re-computes and compares + that fingerprint every single time it touches the thing — not just once at + the start. A swapped file is caught even if it has the right name. +2. **Never trust a path.** A file path can secretly be a signpost (symlink) + pointing somewhere else, and a file can be swapped in the instant between + "check it" and "open it." The code looks before opening, opens, then looks + again to make sure it's still the same file. +3. **Write once, never overwrite.** Ceremony history is append-only. New + records link to the previous one by fingerprint (like a blockchain), so + rewriting, reordering, or deleting history breaks the chain visibly. + Publishing uses "create only if it doesn't exist" operations so nothing + authoritative can ever be silently replaced. +4. **One person can't cheat alone.** The coordinator, release signer, auditors, + and participants must all be different people with different keys; releases + need multiple independent sign-offs; and the random beacon comes from a + public source (drand) chosen far enough in the future that nobody can know + it in advance. +5. **Assume the input is hostile.** Every byte parsed — JSON, curve points, + sizes, timestamps — is checked for exactly one canonical form, exact length, + and sane bounds before it's used. Two different encodings of "the same" + thing are treated as an attack, not a convenience. + +The known gaps section at the end lists the handful of places where these +ideas are not yet applied consistently. + +## 1. Filesystem + +### Symlink attacks (CWE-59) + +Attack: plant a symlink at an expected path so the tool reads or writes +somewhere else (another user's key, `/etc/passwd`, an attacker-controlled file). + +- `openRegularExact` Lstats and rejects `ModeSymlink` and non-regular files + before opening — `internal/mpcceremony/files.go:127-133` +- `readRegularBounded` same pattern for signed records and keys — + `internal/mpcceremony/workflow.go:2165-2174` +- Publication file/tree inspection rejects symlinks and non-regular entries — + `internal/mpcceremony/publication.go:101-106,301,322` +- Key bundle reads require a regular file with secret permissions — + `internal/keybundle/keybundle.go:232-244` +- CLI inputs reject symlinks — `cmd/mpc-ceremony/ops.go:309-314`, + `cmd/mpc-ceremony/executor.go` (`readPublicKeyHex`) +- Walk/copy paths reject symlink entries — + `internal/mpcceremony/audit.go:1517-1519`, `decision.go:1068`, + `finalize.go:1741-1746` +- `rejectSymlinkComponents` Lstats every parent path component and rejects any + symlink or non-directory intermediate; its doc comment explicitly disclaims + race-freeness versus `openat2(RESOLVE_NO_SYMLINKS)` — + `internal/mpcceremony/workflow.go:2650-2684` + +### TOCTOU races (CWE-367) + +Attack: swap the file between the check and the open, or mutate it while it is +being read or hashed. + +- `os.SameFile(linkInfo, info)` re-check after open ("changed while being + opened") — `internal/mpcceremony/files.go:141-153` +- SameFile + size check + trailing one-byte read ("changed while being read") — + `internal/mpcceremony/workflow.go:2180-2200` +- SameFile before hashing, size stability during, SameFile + size again after + ("changed while being hashed") — `internal/mpcceremony/publication.go:121-150` +- Tree inspection re-Lstats the root after the walk to detect a mid-walk swap — + `internal/mpcceremony/publication.go:296-356` +- `copyRegularNoReplace` triple-checks source identity/size before, during, and + after the copy — `internal/mpcceremony/audit.go:1416-1468` +- Running-executable digest re-checks size mid-hash — + `internal/mpcceremony/software.go:343-370` +- Key bundle reads: SameFile + size + trailing-byte read — + `internal/keybundle/keybundle.go:250-267` +- Key manifest re-compared (`reflect.DeepEqual`) after signature verification + ("manifest changed after signature verification") — + `internal/keybundle/keybundle.go:141-146` + +### Path traversal / containment (CWE-22) + +Attack: artifact names or URLs that escape the intended directory +(`../../…`, absolute paths, scheme smuggling). + +- `validateArtifactName`: rejects `\`, leading `/`, non-clean paths, `.`; + bounds length and requires UTF-8 — `internal/mpcceremony/model.go:543-551` +- `resolveArtifactPath`: absolute-path + `filepath.Rel` containment (rejects + `..` escapes) + symlink-component rejection — + `internal/mpcceremony/workflow.go:2605-2625` +- `logicalPathWithin` for outputs rejects `.`/`..`/escapes — + `internal/mpcceremony/workflow.go:2627-2648` +- `safeRelativePath` rejects absolute paths, `\`, `://`, `?`, `#`, non-clean — + `internal/proofassets/chunk_manifest.go:920-923` +- `resolveChunkURL` rejects `\`, `://`, `?`, `#`, `../`, non-clean; requires an + absolute base URL with scheme and host — + `internal/msmengine/sharded_js.go:367-390` +- Path flags reject `-` (stdin) and URLs — `cmd/mpc-ceremony/parse.go:955-966` + +### Overwrite / partial-state attacks on authoritative records + +Attack: replace, truncate, or roll back already-published ceremony state; leave +a torn write that later reads as valid. + +- `atomicWriteNoReplace`: temp file in the same directory, 0600, size check, + fsync, strict read-back validation, hard-link publish (never replaces) — + `internal/mpcceremony/files.go:266-336` +- `publishFileWithOps`: `link()` publish, destination identity via SameFile, + byte and mode revalidation, parent fsync with recovery retry — + `internal/mpcceremony/publication.go:168-287` +- Directory publication via `RENAME_NOREPLACE`; rejects empty staging; + idempotent recovery only for a byte-exact existing tree — + `internal/mpcceremony/publication.go:378-525` +- `publicationError` commit-state tracking so a committed publication is never + rolled back by cleanup defers — `internal/mpcceremony/publication.go:18-46` + (used at `workflow.go:289,689,1822,1958`) +- `O_WRONLY|O_CREATE|O_EXCL` with 0600 for new files — + `internal/mpcceremony/audit.go:1437`, `finalize.go:1872-1911` +- `requireAbsentOrExact`: a retry may only succeed against a byte-identical + existing artifact; any mismatch aborts — + `internal/mpcceremony/workflow.go:2230-2256` +- Signature published before its record, so a record can never exist without + its signature — `internal/mpcceremony/workflow.go:2209-2227` +- Durability: `syncDirectory` — `internal/mpcceremony/files.go:383-393`; + fsync-failure recovery re-validates before retrying — + `internal/mpcceremony/publication.go:527-552` + +### Permissions + +Attack: key material readable by other local users. + +- `requirePrivateRealDirectory` rejects group/world permission bits — + `internal/mpcceremony/workflow.go:2401-2413` +- `mkdirAllPrivateDurable`: 0700, per-level real-directory checks, parent + fsync — `internal/mpcceremony/workflow.go:2460-2498` +- Directory-member allowlist; only `..partial-*` temporaries may be + reaped — `internal/mpcceremony/workflow.go:2415-2458` +- Private key files must be mode 0600 or stricter — + `internal/keybundle/keybundle.go:239-241` + +### Resource exhaustion + +Attack: oversized inputs exhaust memory or disk. + +- `MaxArtifactSize` = 16 GiB, fail-closed — + `internal/mpcceremony/preflight.go:28,188-196` +- Signed records capped at 16 MiB — `internal/mpcceremony/workflow.go:25`; + drand responses at 1 MiB — `internal/mpcceremony/beacon.go:17` +- File sizes must be in `[1, max]` — `internal/mpcceremony/workflow.go:2190-2192` +- Per-file bound and 100,000-entry tree cap in publication — + `internal/mpcceremony/publication.go:108-115,304-311` +- 4096-byte caps on signature/public-key artifacts — + `internal/mpcceremony/decision.go:815,819` +- Per-artifact-type byte caps — `internal/keybundle/keybundle.go:27-31` + +## 2. Cryptographic + +### Forged or replayed records + +Attack: fabricate a signed record, or trust a key named inside the (untrusted) +record itself. + +- `VerifyExact`: schema/algorithm validation, key-ID match, public-key + fingerprint match, signed-data SHA-256 match, then `ed25519.Verify` — + `internal/mpcceremony/attestation.go:70-97` +- `VerifySignedRecord`: authenticate the exact bytes before strict parsing — + `internal/mpcceremony/attestation.go:117-131` +- `LoadSignedDefinition`: requires an external out-of-band coordinator public + key (an in-tree copy is insufficient); the signature's `KeyID` is deliberately + not trusted for role assignment until the external anchor has authenticated + the bytes; identity key cross-checked against the anchor — + `internal/mpcceremony/workflow.go:179-230` +- Offline operational signatures verified over exact canonical bytes before + wrapping — `internal/mpcceremony/operational.go:584-614` + +### Key substitution + +Attack: swap in a different key for an enrolled identity. + +- `identityPublicKey` re-derives and checks the fingerprint on every load — + `internal/mpcceremony/workflow.go:2138-2147` +- Loaded private key must match the enrolled identity's public key — + `internal/mpcceremony/workflow.go:2149-2162` +- Decision signing key must equal the required ceremony identity — + `internal/mpcceremony/decision.go:617-620` +- A 64-byte private key's public half must match its seed derivation — + `internal/keybundle/keybundle.go:194-199` + +### Artifact substitution + +Attack: hand the verifier different bytes than were signed. + +- Every `Digest` carries SHA-256 + BLAKE2b-256 + exact size; tagged lowercase + hex enforced — `internal/mpcceremony/model.go:76-104` +- Every referenced artifact re-hashed against its signed ref before use — + `internal/mpcceremony/workflow.go:2686-2699` +- R1CS digested before native decoding (vector lengths are unsafe from an + unauthenticated file) — `internal/mpcceremony/r1cs.go:271-302` (comment at + 84-87) +- Circuit binding requires exact match of both hashes and serialization size — + `internal/mpcceremony/r1cs.go:68-82` +- Running tool binary must digest-match the signed software binding — + `internal/mpcceremony/software.go:321-330` +- CCS pinned by blake2b/sha256/size against the signed manifest — + `cmd/wasm-prover/main_js.go:1024-1033` + +### Encoding-equivalence attacks + +Attack: two different byte encodings that decode to the same object, defeating +digest-based identity. + +- `requireCanonicalRoundTrip`: re-serialize the decoded gnark object and + require byte-identical size plus both digests — + `internal/mpcceremony/files.go:191-216` +- `streamClone` round-trips through a pipe with byte-count and trailing-byte + equality — `internal/mpcceremony/phase1.go:259-310` + +### Invalid curve points / small subgroups + +Attack: a point that parses but sits outside the prime-order subgroup leaks +secrets via Pohlig–Hellman over the cofactor (the ZKHack trusted-setup +primitive). + +- BLS12-381 compressed-point flag-byte check rejects non-canonical prefixes — + `internal/mpcceremony/preflight.go:427-438` +- Ceremony path uses gnark-crypto decoder defaults with subgroup checks ON, + and `UpdateProof.Verify` additionally runs `IsInSubGroup()` and rejects + infinity (upstream `mpcsetup.go:94-99`) +- `msmengine` pinned decoders skip the subgroup check only on + digest-authenticated bytes and explicitly re-add `IsOnCurve()` per point — + `internal/msmengine/serialize.go:103-122,139-158`; the non-pinned siblings + use `SetBytes` (full validation) — `serialize.go:85-98,124-137` + +### Cross-protocol / context confusion + +Attack: a hash or signature computed for one record type accepted as another. + +- `canonicalHash(domain, value)`: per-record-type domain tag + `0x00` + separator + canonical JSON — `internal/mpcceremony/model.go:421-431`. + Distinct tags for root, phase, acceptance, genesis, close, beacon, seal, + audit, final-transcript, contribution/erasure attestations, signed release, + production decision, and full replay (see `definition.go`, `chain.go`, + `attestation.go`, `decision.go`, `audit.go`) +- `DeriveBeaconChallenge`: domain tag + `0x00`, 4-byte big-endian length + prefix on every variable-length field, 8-byte BE round — unambiguous tuple + encoding — `internal/mpcceremony/chain.go:790-825` +- Public-input digest domain-prefixed — + `internal/mpcceremony/finalize.go:1367-1374` + +### ID substitution + +Attack: reuse a record's contents under a different record ID. + +- Every record ID is content-addressed: recomputed over the record with the ID + field blanked, mismatch rejected, and the ID field required to be empty + during computation — `internal/mpcceremony/chain.go:60-72` (and the parallel + checks in `definition.go`, `attestation.go`, `finalize.go`, `decision.go`) + +### Rigged randomness beacon + +Attack: operator supplies or biases the public randomness. + +- Drand quicknet chain hash, public key, scheme, genesis, and period pinned in + the signed definition — `internal/mpcceremony/model.go:317-355` +- `VerifyDrandBeaconResponse`: real BLS verification against the pinned key; + randomness derived as `sha256(verified signature)`, never taken from the + response; unchained schemes' `previous_signature` rejected — + `internal/mpcceremony/beacon.go:44-107` +- Caller-supplied challenge values rejected unless equal to the deterministic + derivation — `internal/mpcceremony/chain.go:629-634` + +### A verifier that accepts anything + +Attack: a broken or stubbed verifier reports success on garbage. + +- Negative-control verification at finalization: after the positive check, the + verifier must *reject* a changed destination, changed credential, changed + digest, bit-flipped proof, wrong verifying key, truncated proof, and + appended proof; all eight report booleans required true — + `internal/mpcceremony/finalize.go:1313-1363,223-232` +- Wrong-key negative control negates `G1.K[0]` (mutating `Alpha` would not be + a valid negative test because the verifier uses the precomputed pairing) — + `internal/mpcceremony/finalize.go:1426-1455` + +### Crash-as-oracle / denial via panic + +- Panic boundaries around gnark decode/verify of untrusted input — + `internal/mpcceremony/files.go:338-381`, + `internal/mpcceremony/phase1.go:312-336` + +## 3. Serialization + +Attack class: JSON smuggling (duplicate keys, unknown fields, trailing data), +non-canonical encodings that alias distinct digests, length-field lies, +integer overflow. + +- `MarshalCanonical`: rejects nil and `map[string]any`; requires `Validate()` — + `internal/mpcceremony/model.go:363-382` +- `UnmarshalCanonical`: duplicate-key scan, `DisallowUnknownFields`, + trailing-token rejection, `Validate()`, then re-marshal and require byte + equality with the input — `internal/mpcceremony/model.go:386-419` +- Recursive duplicate-key detection with `UseNumber()` — + `internal/mpcceremony/model.go:433-501` +- `strictjson`: max depth 64, max 100,000 object keys, duplicate-key and + trailing-value rejection — `internal/strictjson/strictjson.go:14-17,75-106` +- Drand JSON parsed strictly before any crypto — + `internal/mpcceremony/beacon.go:58-69,109-118` +- `nativeReadExact`: `io.LimitedReader` at the exact expected size; decoder + must consume exactly that and leave zero trailing bytes — + `internal/mpcceremony/files.go:172-189` +- Preflight scanner tracks consumed bytes, rejects overrun, and proves EOF + with a one-byte read — `internal/mpcceremony/preflight.go:383-393,497-509` +- `checkedAdd`/`checkedMul`/`checkedSub` via `math/bits` for all size + arithmetic — `internal/mpcceremony/preflight.go:198-219` +- `MaxDomainN = 2^32` (BLS12-381 2-adicity), `MaxPhase2Commitments = 255` + (gnark's 1-byte commitment domain tag aliases beyond that) — + `internal/mpcceremony/preflight.go:20-24` +- Phase 2 shape must come from the locally compiled R1CS, never from an + untrusted artifact — `internal/mpcceremony/preflight.go:57-63`, enforced at + `workflow.go:2707-2747` and `files.go:103-124` +- Stream length prefixes must equal locally derived expected lengths before + any allocation — `internal/mpcceremony/preflight.go:458-470` +- streampk domain header: canonical-flag byte check, trailing-byte rejection, + every FFT domain field recomputed against `fft.NewDomain` — + `internal/streampk/keysource.go:163-217` +- Timestamps must be UTC `Z` and round-trip canonically through RFC3339Nano — + `internal/mpcceremony/model.go:553-565` +- Hex must be exact-length lowercase (rejects mixed-case aliasing) — + `internal/mpcceremony/model.go:510-522` + +## 4. Identity / roster + +Attack class: one actor holding multiple roles (Sybil), colluding role +overlap, duplicate enrollment. + +- Release signer distinct from coordinator by ID and key ID — + `internal/mpcceremony/definition.go:161-163` +- At least two auditors; uniqueness across coordinator/release signer/auditors + in three dimensions: identity ID, key ID, public-key fingerprint — + `internal/mpcceremony/definition.go:164-198` +- Roster uniqueness against all prior roles, same three dimensions — + `internal/mpcceremony/definition.go:199-225` +- Same three-dimension uniqueness re-applied at enrollment input — + `internal/mpcceremony/workflow.go:68-123` +- Phase policy: non-empty, ≤ 20 participants, minimum within bounds, all IDs + in roster, no duplicates — `internal/mpcceremony/model.go:177-198` +- A participant may appear at most once per phase chain — + `internal/mpcceremony/chain.go:205-208` +- Exactly two enrolled audits by distinct auditors with distinct key IDs, plus + two external audits with distinct signer fingerprints — + `internal/mpcceremony/decision.go:487-515` +- External auditor keys disjoint from coordinator, release signer, and all + enrolled auditors — `internal/mpcceremony/decision.go:792-803` +- GO decision requires exactly the required signer set — no extras, none + missing; duplicate signatures rejected — + `internal/mpcceremony/decision.go:683-716` +- Public witnesses and mirror operators must not overlap any ceremony actor — + `internal/mpcceremony/operational.go:937-950` +- Transfer sender/recipient distinct — `internal/mpcceremony/operational.go:1007-1024` +- IDs restricted to `[a-z0-9-_.:]`, 1–128 chars — + `internal/mpcceremony/model.go:531-541` + +## 5. Transcript / chain integrity + +Attack class: rewrite, reorder, fork, or truncate ceremony history; splice a +contribution that was never verified. + +- `Chain.Validate`: strictly increasing timestamps, contiguous 1-based + indices, `PreviousPayload` = accepted head, `PreviousRecordID` = prior + record ID (hash chaining), ceremony/phase identity match, ≤ 20 records — + `internal/mpcceremony/chain.go:159-214` +- `Append` validates the entire candidate chain before mutating — + `internal/mpcceremony/chain.go:216-227` +- Accepted payload must differ from the previous payload (no no-op + contributions) — `internal/mpcceremony/chain.go:106-108,374-376` +- Domain-separated genesis anchor — `internal/mpcceremony/chain.go:382-398` +- Chain participants must match the frozen scheduled order from the signed + definition — `internal/mpcceremony/chain.go:283-297` +- `ValidateAttestationAcceptance`: record must be the next child of the head + (index, payload, and record ID all three); 10-field binding between record + and attestation; software binding equality; full chronology (contributed + after created, after previous acceptance; accepted after destruction) — + `internal/mpcceremony/chain.go:301-380` +- gnark contribution challenge must equal SHA-256 of the previous payload — + binds the native transcript to the JSON chain — + `internal/mpcceremony/workflow.go:2865-2877` +- `verifyChainFiles`: every record's native payload re-digested; participant + attestation, erasure, and coordinator verification records verified; + growing-prefix revalidation — `internal/mpcceremony/workflow.go:2701-2828` +- Full replay from deterministic genesis with per-step `previous.Verify(next)`; + clone-before-verify so archived inputs are never mutated — + `internal/mpcceremony/phase1.go:145-205`, `phase2.go:228-292` +- Replayed shape must equal the signed circuit binding — + `internal/mpcceremony/phase2.go:264-268` +- Erasure attestation binds the contribution in 8 fields; destruction must + postdate contribution — `internal/mpcceremony/attestation.go:257-280` +- Coordinator verification record must match the chain record field-for-field — + `internal/mpcceremony/workflow.go:1323-1343` +- Transfer receipts bind `sha256(exact handoff bytes)` plus 10 scope fields, + with a validity window — `internal/mpcceremony/operational.go:709-724` +- Operational evidence must cover every accepted head and terminate at the + close record's head — `internal/mpcceremony/operational_bundle.go:544-549` + +## 6. Network / download + +- `internal/mpcceremony` imports no networking; verification never fetches a + URI or trusts mutable network state — + `internal/mpcceremony/decision.go:82-84` +- Evidence URIs restricted to `https`/`ipfs`, canonical encoding, no userinfo, + no fragment, host required, ≤ 2048 bytes; recorded, never fetched — + `internal/mpcceremony/decision.go:1322-1342` +- `Content-Encoding` must be empty or `identity` (blocks transparent- + decompression length/digest confusion) — + `internal/msmengine/sharded_js.go:326-328`, + `apps/ownership-proof-web/public/proof-runtime/msm-worker.js:313-326` +- Exact-size reads via `LimitReader(size+1)` — + `internal/msmengine/sharded_js.go:329-335` +- Dual-digest chunk verification before use; verify-before-cache (no error + path can populate the LRU) — `internal/msmengine/sharded_js.go:337-364`, + `msm-worker.js:313-326` +- Compressed CCS: wire bytes hashed and length-checked against a signed pin + while inflating; trailer drained; mismatch falls back to the fully pinned + identity asset (cannot downgrade integrity) — + `cmd/wasm-prover/main_js.go:1187-1211` +- Unpinned compile fallback refused when `ccs_url` is absent — + `cmd/wasm-prover/main_js.go:1043` +- Manifest signature URL and public key must be supplied together — + `cmd/wasm-prover/main_js.go:1449-1495` +- Readahead discards bodies; integrity enforced only at consumption — + `cmd/wasm-prover/readahead_js.go:14-21` +- Section byte ranges bounds-checked against the plan's file size — + `internal/msmengine/sharded_js.go:282-284` + +## 7. Process / operational + +### Beacon precommitment + +Attack: coordinator who already knows the beacon output closes the phase +around it. + +- `beacon_not_before` must postdate close and exactly equal the pinned + quicknet round schedule — `internal/mpcceremony/chain.go:493-509` +- Round must be in the future at close; lead ≥ signed minimum — + `internal/mpcceremony/chain.go:567-589` +- Lead re-checked immediately before the atomic publish, with a 2-second + safety margin and a clock-monotonicity check — + `internal/mpcceremony/workflow.go:1538-1583,28` +- Production requires ≥ 24h witness lead — + `internal/mpcceremony/definition.go:8,232-239` +- Phase 2 beacon round must differ from Phase 1's (no round reuse) — + `internal/mpcceremony/workflow.go:1409-1414` +- Beacon `published_at` must not precede the committed time or round schedule — + `internal/mpcceremony/chain.go:755-764` +- Challenge must be exactly 32 bytes; future-round requirement mandatory — + `internal/mpcceremony/model.go:345-353` +- Round-time arithmetic overflow-checked — + `internal/mpcceremony/chain.go:773-785` + +### Quorum weakening + +- Public-witness quorum ≥ 2; receipts must meet it, with witness ID and key + fingerprint de-duplication and unanimity on closure and round — + `internal/mpcceremony/operational.go:741-781`, + `operational_bundle.go:110-119` +- Multi-relay beacon: 3–16 observations, distinct relay IDs, distinct + operator IDs, distinct endpoint digests, unanimous verified randomness — + `internal/mpcceremony/operational.go:394-427` +- 2–8 immutable mirror receipts per accepted head — + `internal/mpcceremony/operational_bundle.go:72-75` +- ≥ 2 independent audits — `internal/mpcceremony/chain.go:1174-1176`, + `audit.go:867-868` + +### Production-mode hardening + +- Production requires all scheduled participants accepted (rehearsal permits + ≥ minimum); ≥ 2 roster participants and ≥ 2 scheduled per phase with + `minimum == len(participants)` — `internal/mpcceremony/chain.go:530-539`, + `definition.go:240-254` + +### Supply chain + +- Production requires a clean git tree and exact build profile: pinned Go + version, GOOS/GOARCH/GOAMD64, compiler, buildmode, `CGO_ENABLED=false`, + `trimpath` — `internal/mpcceremony/software.go:433-463`, + `definition.go:124-139` +- VCS must be git; revision 40 lowercase hex, not all-zero; `vcs.modified` + false in production — `internal/mpcceremony/software.go:172-208,491-504` +- Module `replace` directives rejected in production; duplicate build + settings and linked modules rejected — + `internal/mpcceremony/software.go:383-400,465-489` +- Production executable identity read from `/proc/self/exe` — + `internal/mpcceremony/software.go:41-50` +- Running software re-verified against the signed definition on every + operational command — `internal/mpcceremony/workflow.go:232-244` + +### Separation of duties + +- Release signing requires ≥ 2 distinct enrolled passing audits and a + distinct pre-existing release key; release directory must differ from the + candidate directory — `internal/mpcceremony/audit.go:277-343` +- Audits must bind the exact candidate replay root and output set, and + postdate candidate finalization — `internal/mpcceremony/audit.go:862-956` +- Release must strictly postdate every audit — + `internal/mpcceremony/audit.go:958-963` +- Release self-verified via full `VerifyRelease` before publication — + `internal/mpcceremony/audit.go:469-479` +- `PrepareFinalization` output is explicitly not a candidate and is rejected + by audit/release commands — `internal/mpcceremony/finalize.go:451-455` +- "Trust the published seal" shortcut restricted to coordinator acceptance; + contribution/close/finalize/audit paths must independently replay Phase 1 + before sampling secret randomness — + `internal/mpcceremony/workflow.go:2879-2885` +- GO decision requires coordinator + both auditors + release signer, exactly — + `internal/mpcceremony/decision.go:705-716,1253-1260` + +### Contribution environment and erasure + +- Contribution attestation requires OS CSPRNG, swap disabled, crash dumps + disabled, telemetry disabled, ephemeral environment, destruction plan — + `internal/mpcceremony/attestation.go:144-156` +- Erasure attestation requires process termination, ephemeral storage + destroyed, no backup retained — + `internal/mpcceremony/attestation.go:249-251` + +### Ordering of secret sampling + +- All deterministic preflights complete before MPC entropy is sampled; the + candidate directory is created after replay so a crash cannot strand an + empty candidate — `internal/mpcceremony/workflow.go:675-692` +- Participant must be the one scheduled at the exact index — + `internal/mpcceremony/workflow.go:664-668` + +### Release / evidence tree exactness + +- `verifyReleaseTreeExact`: no unexpected, missing, symlinked, or non-regular + entries — `internal/mpcceremony/audit.go:1486-1543` +- Release tree walk rejects any unpinned file; every pinned artifact must be + present with the exact digest — `internal/mpcceremony/decision.go:1043-1103` +- `verifyChecksumsExact`: exact entry count, sorted order, no duplicates, + digest re-verification — `internal/mpcceremony/audit.go:1021-1071` +- Release artifacts strictly ordered by unique logical name, 16–4096 files — + `internal/mpcceremony/decision.go:196-205,1035-1039` +- One name / one URI may not map to conflicting evidence — + `internal/mpcceremony/decision.go:1262-1276` + +### Governance + +- Restart must bind a genuinely fresh ceremony ID; `new_ceremony_id` + forbidden on non-restart records — + `internal/mpcceremony/operational.go:493-502,885-905` +- Passing audit must have zero findings; failing audit ≥ 1 — + `internal/mpcceremony/chain.go:1031-1041` + +## 8. Other + +- **CLI error redaction**: every caller-supplied argument value replaced with + `` in diagnostics (unexpected positionals can be seed phrases); + longest-first replacement avoids partial-substring leaks — + `cmd/mpc-ceremony/main.go:140-176` +- **Secret exclusion from published evidence**: master XPrv, seed, derivation + path, and wallet material excluded from `PublicFinalizationEvidence` — + `internal/mpcceremony/finalize.go:262-265` +- **Golden-vector pinning**: public evidence must use the exact repository + golden public vector — `internal/mpcceremony/finalize.go:289-292` +- **No mutable discovery**: fixed sidecar paths; no `latest` lookup or + directory scan — `internal/mpcceremony/workflow.go:34-42`, + `finalize.go:63-65` +- **Fail-closed release verification**: requires an out-of-band trusted public + key; refuses to verify without the native proving key — + `internal/mpcceremony/audit.go:504-509` +- **Integer/type safety on 32-bit wasm**: `nbWires` overflow guard — + `internal/streampk/keysource.go:143-145`; Phase 2 shape derivation overflow + guards — `internal/mpcceremony/r1cs.go:352-386` + +## Known gaps + +1. **Ed25519 identity keys are not validated as curve points — FIXED + 2026-08-13.** `Identity.Validate` previously checked only that the key is + 32 bytes of hex. Small-order/non-canonical points were accepted, and stdlib + `ed25519.Verify` (`attestation.go:93`) does not reject small-order keys — a + small-order public key admits signatures that verify for any message. + Non-canonical encodings would also have evaded the fingerprint-based + duplicate-key detection (`definition.go:218`). Now fixed: + `validateEd25519PublicKey` (`internal/mpcceremony/model.go`) decodes with + `filippo.io/edwards25519`, requires canonical encoding (re-encoded bytes + must equal input), and rejects small-order points via + `MultByCofactor == identity`. +2. **`streampk` URL path skips subgroup checks with no compensating + verification.** `internal/streampk/keysource.go:116,133,378,393` use + `NoSubgroupChecks()` with no `IsOnCurve` and no digest verification on the + URL path. Documented as finding D2 in + `docs/mpc-ceremony-proposed-changes.md:255-329`. +3. **`Identity.DisplayName` is unbounded and permits control characters — + FIXED 2026-08-15.** `Identity.Validate` checked only trimming and UTF-8 + validity, so there was no length cap and interior ANSI escapes, bidi + overrides, and zero-width characters passed into signed records, logs, and + transcripts. `validateArtifactName` was partially hardened 2026-08-13 + (512-byte cap, `unicode.IsControl`, no untrimmed path segments) but shared + the same blind spot, because `unicode.IsControl` reports Unicode category + **Cc** only, while every bidi and zero-width character is category **Cf**. + + Both validators now share `rejectDeceptiveRunes` (`model.go:643-674`), which + rejects control characters, the bidi formatting set + (`U+202A`-`U+202E`, `U+2066`-`U+2069`, `U+200E`, `U+200F`), and `U+200B`. + `validateDisplayName` (`model.go:620-641`) adds a 256-byte cap. The bidi and + zero-width sets are listed explicitly rather than rejecting all of category + Cf, because `U+200C` (ZWNJ) is required for Persian and Indic text and + `U+200D` (ZWJ) joins emoji sequences; a blanket ban would make legitimate + names unwritable. Covered by `deceptive_names_test.go`, including the + over-blocking cases. + + Severity was low and remains worth recording: `DisplayName` is never read + for a decision — four references in the tree, all declaration, validation, + or construction — and identity is keyed on ID, key ID, and public-key + fingerprint. Nothing was forgeable. The target was the human review step + that the audit and release stages depend on, via the Trojan Source technique + (CVE-2021-42574) applied to attested names rather than source code. + +4. **Whitespace-only values passed presence checks in two attested fields — + FIXED 2026-08-13.** `ContributionEnvironment.OS`/`.Architecture` + (`attestation.go:145`) and audit findings (`chain.go:1038`) used plain + `== ""`, so `" "` satisfied "must not be empty." Both now require trimmed, + non-empty values, matching the `DisplayName` convention. From bb39e3d714e2e5b143a8084ef3deaf5a9d4b2acb Mon Sep 17 00:00:00 2001 From: Jason Park Date: Sun, 16 Aug 2026 02:33:14 +0000 Subject: [PATCH 11/42] Derive the beacon round from the clock sampled after replay A phase close names its beacon round up front, then replays the entire accepted phase, then stamps closed_at and checks the round is still in the future with the signed witness lead intact. At domain 2^21 that replay runs for hours, so naming the round first asks the coordinator to predict their own hardware. Guess low and the whole replay is discarded. This is what caused the 2026-07-24 closure-timing incident, and it recurred on 2026-08-16 during a production-mode run that chose the round from the signed lead plus a margin, which is the only rule written down anywhere. The signed minimum_witness_lead_seconds states how long witnesses need; it says nothing about how long this host takes to replay. Those quantities are unrelated and only the first is recorded in the ceremony. Add --beacon-round-lead as an alternative to --beacon-round, deriving the round from closed_at plus the larger of the requested lead and the signed minimum, plus the publication safety margin that validateCloseCommitTime re-checks against a second clock sample. FirstQuicknetRoundAfter inverts QuicknetRoundTime; rounds are arithmetic from the pinned genesis, so this needs no network access. Deriving later commits to nothing sooner. The round is not published, signed, or observable until the closure record is written at the end, so the choice is indistinguishable to every observer, and under either ordering the round is in the future and its randomness does not yet exist. The derivation cannot live in the CLI. Only the package knows when the replay finished, and closed_at is sampled inside publishReplayedPhaseClose; a CLI deriving beforehand would be making the same blind guess. Two checks assumed an explicit round and are narrowed rather than removed. Retry recovery compares a published closure's round against the requested one, which a derived round has no operator intent to contradict, so it now applies only when a round was named; the existing record is authenticated and fully revalidated either way. The phase 2 round-reuse check runs before the replay, so a derived round is checked for reuse after derivation. --- cmd/mpc-ceremony/executor.go | 1 + cmd/mpc-ceremony/integration_test.go | 1 + cmd/mpc-ceremony/parse.go | 10 +- cmd/mpc-ceremony/types.go | 1 + cmd/mpc-ceremony/usage.go | 18 ++- docs/mpc-ceremony-proposed-changes.md | 54 ++++++++ .../beacon_round_derivation_test.go | 120 ++++++++++++++++++ internal/mpcceremony/chain.go | 28 ++++ internal/mpcceremony/integration_test.go | 2 +- internal/mpcceremony/workflow.go | 82 ++++++++++-- 10 files changed, 301 insertions(+), 16 deletions(-) create mode 100644 internal/mpcceremony/beacon_round_derivation_test.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index d22e5bd..a43d3a1 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -297,6 +297,7 @@ func executeClose(phase mpcceremony.Phase, options CloseOptions) (CommandResult, Phase1SealSignaturePath: options.Phase1SealSignaturePath, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, BeaconRound: options.BeaconRound, + BeaconRoundLeadSeconds: uint32(options.BeaconRoundLeadSeconds), }) if err != nil { return CommandResult{}, err diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 75f83ce..b6503f6 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -98,6 +98,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--beacon", "--beacon-signature", "--beacon-round", + "--beacon-round-lead", "--candidate-bundle", "--candidate-dir", "--ceremony", diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 9127a57..cdc108f 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -560,6 +560,8 @@ func parseClose(name string, args []string, phase2 bool) (CloseOptions, error) { fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached final accepted chain signature path") fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path") fs.Uint64Var(&options.BeaconRound, "beacon-round", 0, "precommitted future beacon round") + fs.UintVar(&options.BeaconRoundLeadSeconds, "beacon-round-lead", 0, + "derive the beacon round this many seconds past the clock sampled after replay") if err := parseFlags(fs, args); err != nil { return options, err } @@ -572,8 +574,12 @@ func parseClose(name string, args []string, phase2 bool) (CloseOptions, error) { pathValue("--chain-signature", options.ChainSignaturePath), pathValue("--coordinator-signing-key", options.CoordinatorSigningKey), } - if options.BeaconRound == 0 { - required = append(required, requiredValue{name: "--beacon-round"}) + // A close replays for hours at K=21 before it stamps closed_at, so naming + // the round up front asks the operator to predict their own replay time. + // --beacon-round-lead derives it from the clock sampled after the replay. + if (options.BeaconRound == 0) == (options.BeaconRoundLeadSeconds == 0) { + return options, errors.New( + "exactly one of --beacon-round and --beacon-round-lead is required") } if phase2 { required = append( diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index edfa89d..9da5949 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -113,6 +113,7 @@ type CloseOptions struct { ChainSignaturePath string CoordinatorSigningKey string BeaconRound uint64 + BeaconRoundLeadSeconds uint } type Phase1SealOptions struct { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 56d8822..56f4e7b 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -131,11 +131,18 @@ phase close perform the independent full-prefix replays. mpc-ceremony phase1 close --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --transcript-dir DIR --chain FILE \ --chain-signature FILE --coordinator-signing-key KEY \ - --beacon-round N + --beacon-round N | --beacon-round-lead SECONDS Replays the full phase, derives the exact Quicknet schedule from the round, samples closed_at inside the core after replay, and atomically publishes the signed closure only while the policy lead still holds. + +At K=21 the replay takes hours, so --beacon-round asks you to predict it: a +round named too near is already public when the closure is written and the +whole replay is discarded. --beacon-round-lead instead derives the round from +the clock sampled after the replay, at least SECONDS ahead and never below the +signed witness lead. The round is not published or observable until the closure +record is written either way, so deriving it later commits to nothing sooner. `, "phase1 beacon": `Usage: mpc-ceremony phase1 beacon --ceremony FILE --ceremony-signature FILE \ @@ -201,11 +208,18 @@ Participant contribution and phase close retain independent full replays. --coordinator-public-key-file KEY --phase1-seal FILE \ --phase1-seal-signature FILE --transcript-dir DIR --chain FILE \ --chain-signature FILE --coordinator-signing-key KEY \ - --beacon-round N + --beacon-round N | --beacon-round-lead SECONDS Replays the full phase, derives the exact Quicknet schedule from the round, samples closed_at inside the core after replay, and atomically publishes the signed closure only while the policy lead still holds. + +At K=21 the replay takes hours, so --beacon-round asks you to predict it: a +round named too near is already public when the closure is written and the +whole replay is discarded. --beacon-round-lead instead derives the round from +the clock sampled after the replay, at least SECONDS ahead and never below the +signed witness lead. The round is not published or observable until the closure +record is written either way, so deriving it later commits to nothing sooner. `, "phase2 beacon": `Usage: mpc-ceremony phase2 beacon --ceremony FILE --ceremony-signature FILE \ diff --git a/docs/mpc-ceremony-proposed-changes.md b/docs/mpc-ceremony-proposed-changes.md index 0bb2256..d7ea1f5 100644 --- a/docs/mpc-ceremony-proposed-changes.md +++ b/docs/mpc-ceremony-proposed-changes.md @@ -118,6 +118,60 @@ through one helper that redacts by construction, so a new call site cannot opt out by accident; and add a minimum-length floor in `addCLIErrorCandidate` to stop short values blanking unrelated text. Neither changes the trust boundary. +### A5 · The beacon round is chosen before the replay that decides whether it is still valid — medium, verified + +`phase1 close` and `phase2 close` take `--beacon-round N` up front, then replay +the whole accepted phase, then sample `closed_at` and check the round is still +in the future with the signed lead intact. At K=21 that replay takes hours, so +the operator is really being asked to predict their own hardware: name a round +too near and the entire replay is discarded. + +This is the same failure as the 2026-07-24 incident. A3 added replay progress +reporting, which tells an operator how long the replay took once they have +already run one — so it informs the round they pick when retrying a close that +was just rejected, and does nothing for the first close on a given host, which +is the one that must be guessed blind. It +was hit again on 2026-08-16 during a full production-mode run, on a machine +whose replay had never been measured, by picking the round from the signed lead +plus a margin — which is the only rule written down anywhere. Measured cost of +the discarded attempt: 1h40m of replay, from this progress output: + + replaying phase1 contribution 1/3 (48m34s elapsed) + replaying phase1 contribution 2/3 (1h14m34s elapsed) + replaying phase1 contribution 3/3 (1h40m24s elapsed) + +The signed `minimum_witness_lead_seconds` states how much time *witnesses* need. +It says nothing about how long *this host* takes to replay. Those are unrelated +quantities and only the first is recorded in the ceremony. + +Nothing requires the round to be chosen early. It is not published, signed, or +observable until the closure record is written at the end, so choosing it after +the replay is indistinguishable to every observer and cannot help a coordinator: +the round is still in the future at publication, and its randomness does not +exist under either ordering. + +**Fix — implemented 2026-08-16.** `--beacon-round-lead SECONDS` on both close +commands, mutually exclusive with `--beacon-round`. + +The derivation has to happen inside the package, not the CLI. Only the package +knows when the replay finished, and `closedAt` is sampled in +`publishReplayedPhaseClose` after it; a CLI deriving beforehand would be making +the same blind guess. `FirstQuicknetRoundAfter` (`chain.go`) inverts +`QuicknetRoundTime`, and the round is derived from `closedAt` plus the larger of +the requested lead and the signed minimum, plus the publication safety margin +that `validateCloseCommitTime` re-checks against a second clock sample. + +Two existing checks assumed an explicit round and were narrowed rather than +removed. Retry recovery compares a published closure's round against the +requested one; with derivation there is no operator intent to contradict, so the +comparison now applies only when a round was named, and the existing record is +authenticated and fully revalidated either way. The phase 2 round-reuse check +runs before the replay, so a derived round is checked for reuse after +derivation instead. + +`--beacon-round` is unchanged, for staged runs where the round is announced out +of band. + ## B · Documentation integrity ### B1 · Eight governance documents were stripped from `main`; ten links to them remain — high, verified diff --git a/internal/mpcceremony/beacon_round_derivation_test.go b/internal/mpcceremony/beacon_round_derivation_test.go new file mode 100644 index 0000000..d65cb62 --- /dev/null +++ b/internal/mpcceremony/beacon_round_derivation_test.go @@ -0,0 +1,120 @@ +package mpcceremony + +import ( + "testing" + "time" +) + +func TestFirstQuicknetRoundAfterIsStrictlyAfter(t *testing.T) { + for _, round := range []uint64{1, 2, 1000, 31345533} { + scheduled, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + // Landing exactly on a round schedule must advance past it, because the + // close requires the round to be strictly in the future. + next, err := FirstQuicknetRoundAfter(scheduled) + if err != nil { + t.Fatal(err) + } + if next != round+1 { + t.Fatalf("round %d schedule derived %d, want %d", round, next, round+1) + } + nextTime, err := QuicknetRoundTime(next) + if err != nil { + t.Fatal(err) + } + if !nextTime.After(scheduled) { + t.Fatalf("derived round %d is not after %s", next, scheduled) + } + } +} + +func TestFirstQuicknetRoundAfterMidPeriod(t *testing.T) { + base, err := QuicknetRoundTime(1000) + if err != nil { + t.Fatal(err) + } + // One second into a three-second period still resolves to the next round. + next, err := FirstQuicknetRoundAfter(base.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + if next != 1001 { + t.Fatalf("mid-period derived %d, want 1001", next) + } +} + +func TestFirstQuicknetRoundAfterBeforeGenesis(t *testing.T) { + round, err := FirstQuicknetRoundAfter(time.Unix(BeaconQuicknetGenesis-3600, 0)) + if err != nil { + t.Fatal(err) + } + if round != 1 { + t.Fatalf("pre-genesis derived %d, want 1", round) + } +} + +// TestDerivedRoundClearsTheSignedLead is the property the fix exists for: a +// round derived from the post-replay clock must satisfy the same lead check +// that rejects a round an operator named before a multi-hour replay. +func TestDerivedRoundClearsTheSignedLead(t *testing.T) { + const leadSeconds = 600 + closedAt := time.Unix(BeaconQuicknetGenesis+1_000_000, 0).UTC() + lead := leadSeconds * time.Second + + round, err := FirstQuicknetRoundAfter(closedAt.Add(lead + closePublicationSafetyMargin)) + if err != nil { + t.Fatal(err) + } + roundTime, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + if err := validateCloseCommitTime(closedAt, closedAt, roundTime, leadSeconds); err != nil { + t.Fatalf("derived round rejected by the publication guard: %v", err) + } + if roundTime.Sub(closedAt) < lead { + t.Fatalf("derived lead %s is below the signed minimum %s", roundTime.Sub(closedAt), lead) + } +} + +// TestExplicitRoundStaleAfterLongReplayIsRejected reproduces the failure the +// derivation avoids: a round chosen before an hours-long replay is already in +// the past when the closure is published. +func TestExplicitRoundStaleAfterLongReplayIsRejected(t *testing.T) { + const leadSeconds = 600 + chosenAt := time.Unix(BeaconQuicknetGenesis+1_000_000, 0).UTC() + + // The operator picks a round just past the signed lead, as the only written + // rule suggests. + round, err := FirstQuicknetRoundAfter(chosenAt.Add(leadSeconds * time.Second)) + if err != nil { + t.Fatal(err) + } + roundTime, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + + // The replay then takes an hour and forty minutes. + closedAt := chosenAt.Add(100 * time.Minute) + if err := validateCloseCommitTime(closedAt, closedAt, roundTime, leadSeconds); err == nil { + t.Fatal("stale round was accepted after a long replay") + } + + // Deriving from the post-replay clock instead succeeds on the same timeline. + derived, err := FirstQuicknetRoundAfter( + closedAt.Add(leadSeconds*time.Second + closePublicationSafetyMargin), + ) + if err != nil { + t.Fatal(err) + } + derivedTime, err := QuicknetRoundTime(derived) + if err != nil { + t.Fatal(err) + } + if err := validateCloseCommitTime(closedAt, closedAt, derivedTime, leadSeconds); err != nil { + t.Fatalf("derived round rejected: %v", err) + } +} diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index e12e4a3..a543831 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -785,6 +785,34 @@ func QuicknetRoundTime(round uint64) (time.Time, error) { return time.Unix(seconds, 0).UTC(), nil } +// FirstQuicknetRoundAfter returns the earliest round whose scheduled time is +// strictly after the supplied instant. +// +// It is the inverse of QuicknetRoundTime and exists so a phase close can name +// its beacon round using the clock it sampled after replaying, rather than a +// round an operator had to guess before the replay began. Rounds are pure +// arithmetic from the pinned genesis and period, so this needs no network +// access and stays deterministic. +func FirstQuicknetRoundAfter(instant time.Time) (uint64, error) { + seconds := instant.UTC().Unix() + if seconds < BeaconQuicknetGenesis { + return 1, nil + } + period := int64(BeaconQuicknetPeriod) + elapsed := seconds - BeaconQuicknetGenesis + // Round index is one-based, and the result must be strictly after the + // instant, so a time landing exactly on a round schedule advances past it. + round := uint64(elapsed/period) + 2 + roundTime, err := QuicknetRoundTime(round) + if err != nil { + return 0, err + } + if !roundTime.After(instant) { + return 0, errors.New("derived beacon round is not after the supplied instant") + } + return round, nil +} + // DeriveBeaconChallenge maps authenticated public beacon randomness to the // exact 32-byte challenge supplied to gnark. Length prefixes make every input // tuple unambiguous and the domain tag prevents reuse in another protocol. diff --git a/internal/mpcceremony/integration_test.go b/internal/mpcceremony/integration_test.go index fee5979..85702ac 100644 --- a/internal/mpcceremony/integration_test.go +++ b/internal/mpcceremony/integration_test.go @@ -599,7 +599,7 @@ func TestSignedFileWorkflowRejectsReusedPhase1RoundBeforePublicationAndReplays(t t.Fatalf("atomic closure member %q is absent or unsafe: %v", path, err) } } - retriedClose, err := publishReplayedPhaseClose(closeOptions, trusted, loaded.phase1Chain, func() time.Time { + retriedClose, err := publishReplayedPhaseClose(closeOptions, trusted, loaded.phase1Chain, nil, func() time.Time { panic("completed closure retry must not consult the clock") }) if err != nil { diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 8c20063..b794f95 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -1388,7 +1388,25 @@ type ClosePhaseFilesOptions struct { Phase1SealPath string Phase1SealSignaturePath string CoordinatorPrivateKeyPath string - BeaconRound uint64 + // BeaconRound names the future round explicitly. Exactly one of this and + // BeaconRoundLeadSeconds must be set. + BeaconRound uint64 + // BeaconRoundLeadSeconds derives the round instead of naming it, using the + // clock sampled after the replay. + // + // A close replays the entire accepted phase before it stamps closed_at, and + // at domain 2^21 that takes hours. An explicit round therefore forces the + // coordinator to predict their own replay duration: name a round too near + // and the whole replay is discarded for naming a round that was no longer + // in the future. That is what caused the 2026-07-24 closure-timing + // incident. + // + // Deriving here is not weaker. The round is not published, signed, or + // observable until the closure record is written at the end of this + // function, so choosing it before or after the replay is indistinguishable + // to every observer, and under either ordering the round is still in the + // future and its randomness does not yet exist. + BeaconRoundLeadSeconds uint32 } type ClosePhaseFilesResult struct { @@ -1412,6 +1430,10 @@ func closePhaseFiles( if now == nil { return ClosePhaseFilesResult{}, errors.New("closure clock is required") } + if (options.BeaconRound == 0) == (options.BeaconRoundLeadSeconds == 0) { + return ClosePhaseFilesResult{}, errors.New( + "exactly one of beacon round and beacon round lead is required") + } trusted, err := loadOperationalCeremony(options.Trust) if err != nil { return ClosePhaseFilesResult{}, err @@ -1430,21 +1452,27 @@ func closePhaseFilesAuthenticated( } var chain Chain var err error + // Retained past the switch so a derived phase 2 round can be checked for + // reuse of the phase 1 round, which an explicit round is checked for here. + var phase1Close *CloseRecord switch options.Phase { case Phase1: chain, err = LoadReplayPhase1Files(trusted, options.Circuit, options.Transcript) case Phase2: var commons *gnarkmpc.SrsCommons var phase1Seal SealRecord - var phase1Close CloseRecord - commons, phase1Seal, phase1Close, err = loadPhase1CommonsForPhase2( + var loadedClose CloseRecord + commons, phase1Seal, loadedClose, err = loadPhase1CommonsForPhase2( trusted, options.Circuit, options.Transcript.RootDir, options.Phase1SealPath, options.Phase1SealSignaturePath, ) - if err == nil && options.BeaconRound == phase1Close.BeaconRound { + if err == nil { + phase1Close = &loadedClose + } + if err == nil && options.BeaconRound != 0 && options.BeaconRound == loadedClose.BeaconRound { err = fmt.Errorf( "phase2 beacon round %d reuses the authenticated phase1 beacon round; a distinct round is required", options.BeaconRound, @@ -1459,13 +1487,16 @@ func closePhaseFilesAuthenticated( if err != nil { return result, err } - return publishReplayedPhaseClose(options, trusted, chain, now) + return publishReplayedPhaseClose(options, trusted, chain, phase1Close, now) } func publishReplayedPhaseClose( options ClosePhaseFilesOptions, trusted *TrustedCeremony, chain Chain, + // phase1Close is non-nil only for a phase 2 close, and is what a derived + // round is checked against for round reuse. + phase1Close *CloseRecord, now func() time.Time, ) (ClosePhaseFilesResult, error) { var result ClosePhaseFilesResult @@ -1500,7 +1531,10 @@ func publishReplayedPhaseClose( ); err != nil { return result, fmt.Errorf("load existing atomic phase closure: %w", err) } - if existing.BeaconRound != options.BeaconRound { + // Only an explicitly requested round can disagree with what was + // published; a derived round has no operator intent to contradict, and + // the existing record is authenticated and revalidated below either way. + if options.BeaconRound != 0 && existing.BeaconRound != options.BeaconRound { return result, fmt.Errorf( "existing phase closure commits beacon round %d, not requested round %d", existing.BeaconRound, @@ -1523,14 +1557,40 @@ func publishReplayedPhaseClose( return result, fmt.Errorf("inspect phase closure destination: %w", statErr) } - roundTime, err := QuicknetRoundTime(options.BeaconRound) - if err != nil { - return result, err - } closedAt := now().UTC() if closedAt.IsZero() { return result, errors.New("closure clock returned the zero time") } + // Sampled before the round is resolved, so a derived round is measured from + // the moment the replay actually finished. + beaconRound := options.BeaconRound + if beaconRound == 0 { + // The publication guard re-checks the lead against a second clock + // sample and demands the signed minimum plus a safety margin, so derive + // past that rather than past the bare minimum. + lead := time.Duration(options.BeaconRoundLeadSeconds) * time.Second + if minimum := time.Duration( + trusted.Definition.BeaconPolicy.MinimumWitnessLeadSeconds, + ) * time.Second; lead < minimum { + lead = minimum + } + beaconRound, err = FirstQuicknetRoundAfter( + closedAt.Add(lead + closePublicationSafetyMargin), + ) + if err != nil { + return result, fmt.Errorf("derive beacon round from close time: %w", err) + } + if options.Phase == Phase2 && phase1Close != nil && beaconRound == phase1Close.BeaconRound { + return result, fmt.Errorf( + "derived phase2 beacon round %d reuses the authenticated phase1 beacon round", + beaconRound, + ) + } + } + roundTime, err := QuicknetRoundTime(beaconRound) + if err != nil { + return result, err + } closeRecord, err := NewCloseRecord(CloseRecord{ CeremonyID: trusted.Definition.CeremonyID, Phase: options.Phase, @@ -1541,7 +1601,7 @@ func publishReplayedPhaseClose( AcceptedParticipants: participants, BeaconProvider: trusted.Definition.BeaconPolicy.Provider, BeaconNetwork: trusted.Definition.BeaconPolicy.Network, - BeaconRound: options.BeaconRound, + BeaconRound: beaconRound, BeaconNotBefore: roundTime.Format(time.RFC3339Nano), ClosedAt: closedAt.Format(time.RFC3339Nano), CoordinatorID: trusted.Definition.Coordinator.ID, From 6e2529e9235ae1bd9446e36ab0067cb56657a386 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Sun, 16 Aug 2026 07:50:33 +0000 Subject: [PATCH 12/42] Report replay progress from the phase 1 seal Replay progress was added on PhaseTranscriptPaths, which reaches every command whose paths come from the CLI's transcriptPaths helper: contribute, verify, close. The seal was missed. Its options carry a bare transcript root and it builds its own PhaseTranscriptPaths internally, so there was no Progress field to populate and the callback had nowhere to attach. The seal replays the entire phase and then applies the beacon contribution, so it does strictly more work than a close. On a production-mode K=21 run the close reported three progress lines and finished in 1h40m33s while the seal ran silently past 2h25m, which left the longest operation in the ceremony as the only long one that said nothing. SealPhase1FilesOptions now carries Progress and threads it into the paths it constructs, and the CLI attaches the same stderr reporter it already uses. The workflow integration helper asserts the callback fires during a seal so the wiring cannot be dropped again unnoticed. RecordBeaconFiles and InitializePhase2Files also take a bare transcript root but perform no replay, so they need nothing. --- cmd/mpc-ceremony/executor.go | 1 + docs/mpc-ceremony-proposed-changes.md | 19 +++++++++++++++++++ .../testdata/workflowhelper/main.go | 13 +++++++++++++ internal/mpcceremony/workflow.go | 7 +++++++ 4 files changed, 40 insertions(+) diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index a43d3a1..d66a7db 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -372,6 +372,7 @@ func executePhase1Seal(options Phase1SealOptions) (CommandResult, error) { BeaconSignaturePath: options.BeaconSignaturePath, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, OutputDir: options.OutDir, + Progress: replayProgressReporter(), }) if err != nil { return CommandResult{}, err diff --git a/docs/mpc-ceremony-proposed-changes.md b/docs/mpc-ceremony-proposed-changes.md index d7ea1f5..8771dc5 100644 --- a/docs/mpc-ceremony-proposed-changes.md +++ b/docs/mpc-ceremony-proposed-changes.md @@ -91,6 +91,25 @@ beacon-round choice: an operator who can see "contribution 3 of 5, 41 minutes elapsed" can pick a safe round. Neither form prints from the package, and neither carries secret material — an index, a count, and a duration only. +**Coverage gap, found 2026-08-16 and fixed.** The callback landed on +`PhaseTranscriptPaths`, so it reached every command that builds its paths +through the CLI's `transcriptPaths` helper — contribute, verify, close. It did +not reach `phase1 seal`, whose options carry a bare `TranscriptRoot` string and +which constructs its own `PhaseTranscriptPaths` internally (`workflow.go:1881`) +with no `Progress` field to populate. + +The seal replays the entire phase and then applies the beacon contribution, so +it does strictly more work than a close. Observed on a production-mode K=21 run: +the close reported three progress lines and completed in 1h40m33s, while the +seal ran silently past 2h25m. The one operation an operator is most likely to +think has hung was the only long one saying nothing. + +`SealPhase1FilesOptions` now carries `Progress` and threads it into the paths it +builds; the CLI attaches the same reporter it uses elsewhere. The workflow +integration helper asserts the callback fires during a seal, so the wiring +cannot be silently dropped again. `RecordBeaconFiles` and `InitializePhase2Files` +also take a bare root but perform no replay, so they need nothing. + ### A4 · CLI error redaction is a per-call-site blocklist — low, verified Before printing an error, the CLI runs the message through `redactCLIError` diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 90c5f63..cba20ca 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -445,6 +445,10 @@ func run(outputRoot, operationalEvidenceHelper string) error { if err != nil { return fmt.Errorf("record Phase 1 beacon: %w", err) } + // The seal replays the whole phase and is the longest operation in a K=21 + // ceremony, so its progress callback is wired here and asserted below: a + // silent multi-hour command is the defect this reports against. + sealProgress := 0 phase1Seal, err := mpcceremony.SealPhase1Files(mpcceremony.SealPhase1FilesOptions{ Trust: trust, Circuit: circuit, @@ -455,10 +459,19 @@ func run(outputRoot, operationalEvidenceHelper string) error { BeaconSignaturePath: phase1Beacon.SignaturePath, CoordinatorPrivateKeyPath: coordinatorKeyPath, OutputDir: filepath.Join(ceremonyRoot, "phase1", "sealed"), + Progress: func(phase mpcceremony.Phase, index, total int) { + if phase != mpcceremony.Phase1 || index < 1 || index > total { + panic(fmt.Sprintf("seal progress reported %s %d/%d", phase, index, total)) + } + sealProgress++ + }, }) if err != nil { return fmt.Errorf("seal Phase 1: %w", err) } + if sealProgress == 0 { + return errors.New("Phase 1 seal replayed without reporting progress") + } phase2Initialized, err := mpcceremony.InitializePhase2Files(mpcceremony.InitPhase2FilesOptions{ Trust: trust, diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index b794f95..b8e2fe6 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -1847,6 +1847,12 @@ type SealPhase1FilesOptions struct { BeaconSignaturePath string CoordinatorPrivateKeyPath string OutputDir string + // Progress is optional and reports the replay this seal performs before it + // applies the beacon contribution. The seal replays the whole phase and + // then does strictly more work than a close, so at domain 2^21 it is the + // longest operation in the ceremony; without this it is also the only long + // one that is completely silent. + Progress ReplayProgress } type SealPhase1FilesResult struct { @@ -1882,6 +1888,7 @@ func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResu RootDir: options.TranscriptRoot, ChainPath: chainPath, ChainSignaturePath: DefaultSignaturePath(chainPath), + Progress: options.Progress, } chain, replayedHead, err := loadReplayPhase1FilesState(trusted, options.Circuit, chainPaths) if err != nil { From d85ee0d81a7d34931fe2b0a6cec5bbf7cb9d46a7 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Sun, 16 Aug 2026 10:25:32 +0000 Subject: [PATCH 13/42] Report stage progress from phase 2 initialization With the seal covered, phase 2 initialization was still silent past 2h20m on a production-mode K=21 run. This one is not a plumbing omission. InitializePhase2Files performs no replay, so the per-contribution callback has nothing to count: it verifies the sealed phase 1 commons, transforms them into circuit-specific parameters across the whole 2^21 domain, and publishes the result. The transform is a single monolithic computation inside gnark that exposes no progress of its own. ReplayProgress cannot describe that, and a fabricated percentage would be worse than silence. Add StageProgress, which reports entry into a named stage with a one-based index and a total, and report the three stages above. This is coarser than an index into work completed, deliberately. The expensive stage is opaque, so the honest signal is which stage is running rather than an invented fraction of it. It still separates running from hung and names what the operator is waiting on. Like ReplayProgress it carries no secret material and does not print; the CLI renders it to stderr, never stdout. The workflow integration helper asserts all three stages arrive in order. --- cmd/mpc-ceremony/executor.go | 15 ++++++++ docs/mpc-ceremony-proposed-changes.md | 23 +++++++++++-- .../testdata/workflowhelper/main.go | 16 ++++++++- internal/mpcceremony/workflow.go | 34 +++++++++++++++++++ 4 files changed, 85 insertions(+), 3 deletions(-) diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index d66a7db..6febf74 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -410,6 +410,7 @@ func executePhase2Init(options Phase2InitOptions) (CommandResult, error) { Phase1SealSignaturePath: options.Phase1SealSignaturePath, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, OutputDir: options.OutDir, + Progress: stageProgressReporter(), }) if err != nil { return CommandResult{}, err @@ -694,6 +695,20 @@ func replayProgressReporter() mpcceremony.ReplayProgress { } } +// stageProgressReporter renders stage entry to stderr. Phase 2 initialization +// has no contributions to count and its expensive stage is a single call into +// gnark, so naming the running stage is the honest signal available. +func stageProgressReporter() mpcceremony.StageProgress { + start := time.Now() + return func(stage string, index, total int) { + fmt.Fprintf( + os.Stderr, + "stage %d/%d: %s (%s elapsed)\n", + index, total, stage, time.Since(start).Round(time.Second), + ) + } +} + func replayPaths(trust mpcceremony.TrustPaths, replay ReplayOptions) (mpcceremony.ReplayPaths, error) { coordinatorPublicKey, err := readPublicKeyHex(trust.CoordinatorPublicKeyPath) if err != nil { diff --git a/docs/mpc-ceremony-proposed-changes.md b/docs/mpc-ceremony-proposed-changes.md index 8771dc5..bde2748 100644 --- a/docs/mpc-ceremony-proposed-changes.md +++ b/docs/mpc-ceremony-proposed-changes.md @@ -107,8 +107,27 @@ think has hung was the only long one saying nothing. `SealPhase1FilesOptions` now carries `Progress` and threads it into the paths it builds; the CLI attaches the same reporter it uses elsewhere. The workflow integration helper asserts the callback fires during a seal, so the wiring -cannot be silently dropped again. `RecordBeaconFiles` and `InitializePhase2Files` -also take a bare root but perform no replay, so they need nothing. +cannot be silently dropped again. + +**Second gap: the callback shape does not fit every long command.** With the +seal covered, phase 2 initialization was still silent past 2h20m on the same +run. It is not a plumbing omission — `InitializePhase2Files` performs no replay, +so a per-contribution callback has nothing to count. It loads and verifies the +sealed phase 1 commons, transforms them into circuit-specific parameters across +the whole 2^21 domain, and publishes the result; the transform is one monolithic +computation inside gnark that exposes no progress of its own. + +`ReplayProgress` therefore cannot describe it, and reporting a fabricated +percentage would be worse than silence. Added `StageProgress` +(`func(stage string, index, total int)`) and three reported stages, so an +operator sees which stage is running and how long it has been running. Coarser +than a replay index, and honest about it: the value is separating running from +hung and naming what is being waited on. The CLI renders it to stderr like the +replay reporter, and the integration helper asserts all three stages arrive in +order. + +`RecordBeaconFiles` also takes a bare transcript root but is short and performs +no replay, so it needs nothing. ### A4 · CLI error redaction is a per-call-site blocklist — low, verified diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index cba20ca..e6a67f3 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "time" @@ -473,6 +474,10 @@ func run(outputRoot, operationalEvidenceHelper string) error { return errors.New("Phase 1 seal replayed without reporting progress") } + // Phase 2 initialization reports stages rather than contributions, because + // its cost is one monolithic transform rather than a per-contribution + // replay. Assert every stage arrives, in order. + var phase2Stages []int phase2Initialized, err := mpcceremony.InitializePhase2Files(mpcceremony.InitPhase2FilesOptions{ Trust: trust, Circuit: circuit, @@ -480,11 +485,20 @@ func run(outputRoot, operationalEvidenceHelper string) error { Phase1SealPath: phase1Seal.SealPath, Phase1SealSignaturePath: phase1Seal.SignaturePath, CoordinatorPrivateKeyPath: coordinatorKeyPath, - OutputDir: filepath.Join(ceremonyRoot, "phase2"), + Progress: func(stage string, index, total int) { + if stage == "" || index < 1 || index > total { + panic(fmt.Sprintf("phase 2 stage %q reported %d/%d", stage, index, total)) + } + phase2Stages = append(phase2Stages, index) + }, + OutputDir: filepath.Join(ceremonyRoot, "phase2"), }) if err != nil { return fmt.Errorf("initialize Phase 2: %w", err) } + if !slices.Equal(phase2Stages, []int{1, 2, 3}) { + return fmt.Errorf("phase 2 initialization reported stages %v, want [1 2 3]", phase2Stages) + } phase2Paths := mpcceremony.PhaseTranscriptPaths{ RootDir: ceremonyRoot, ChainPath: phase2Initialized.ChainPath, diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index b8e2fe6..2be0d01 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -390,6 +390,24 @@ func InitializeCeremonyFiles(options InitFilesOptions) (result InitFilesResult, // incident. type ReplayProgress func(phase Phase, index, total int) +// StageProgress reports entry into a named stage of a long operation, with a +// one-based index and the total number of stages. +// +// ReplayProgress counts accepted contributions, which suits any command whose +// cost is dominated by replaying a chain. Phase 2 initialization has no +// contributions to count: it loads and verifies the sealed phase 1 commons, +// transforms them into circuit-specific parameters over the whole 2^21 domain, +// and publishes the result. That transform is a single monolithic computation +// running for hours, so a per-contribution callback reports nothing at all. +// +// This is coarser than an index into work completed, and deliberately so. The +// expensive stage lives inside gnark and exposes no progress of its own, so the +// honest signal is which stage is running rather than a fabricated percentage. +// It still separates running from hung, and it names the stage an operator is +// waiting on. Like ReplayProgress it carries no secret material and does not +// print: rendering is the caller's business. +type StageProgress func(stage string, index, total int) + type PhaseTranscriptPaths struct { RootDir string ChainPath string @@ -2010,6 +2028,9 @@ func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResu return result, nil } +// initPhase2StageCount is the number of stages InitializePhase2Files reports. +const initPhase2StageCount = 3 + type InitPhase2FilesOptions struct { Trust TrustPaths Circuit *CompiledCircuit @@ -2018,6 +2039,9 @@ type InitPhase2FilesOptions struct { Phase1SealSignaturePath string CoordinatorPrivateKeyPath string OutputDir string + // Progress is optional and reports stage entry. When nil this runs silent, + // which is the behaviour every existing caller gets. + Progress StageProgress } type InitPhase2FilesResult struct { @@ -2037,6 +2061,14 @@ func InitializePhase2Files(options InitPhase2FilesOptions) (result InitPhase2Fil if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil { return result, err } + // Three stages, of wildly unequal cost. Stage 2 dominates: it transforms the + // commons over the whole domain and is where hours are spent. + stage := func(name string, index int) { + if options.Progress != nil { + options.Progress(name, index, initPhase2StageCount) + } + } + stage("verify sealed phase 1 commons", 1) commons, phase1Seal, _, err := loadPhase1CommonsForPhase2( trusted, options.Circuit, @@ -2051,10 +2083,12 @@ func InitializePhase2Files(options InitPhase2FilesOptions) (result InitPhase2Fil if err != nil { return result, err } + stage("derive circuit-specific phase 2 parameters", 2) initial, shape, err := InitializePhase2(options.Circuit, commons) if err != nil { return result, err } + stage("publish phase 2 genesis", 3) if !equalPhase2Shape(shape, options.Circuit.Binding.Phase2Shape) { return result, errors.New("initialized Phase 2 shape differs from signed circuit binding") } From b921a600b934d5c8f0a6c619f462f2e2e7c49c23 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Mon, 17 Aug 2026 15:35:52 +0000 Subject: [PATCH 14/42] Derive the public evidence vector at the pinned golden path mpc-finalization-evidence derived its credential at account 3, role 2, but PublicFinalizationEvidence.Validate accepts only the credential pinned in GoldenPublicCredentialHex, which is account 0, role 0. The two constants were added in the same commit and never agreed, so the command could not produce evidence any ceremony would accept: error: public evidence does not use the exact repository golden public vector This is on the only path to a finished ceremony. finalize complete requires the evidence, the evidence requires this command, and the failure is reachable only after finalize prepare has replayed both phases to derive the keys. On a K=21 production run that is over thirty hours before the mismatch surfaces. Every other reference in the tree already agrees on account 0, role 0: cmd/api, cmd/proof-tool, cmd/bench-native-prove, internal/verifier, the committed Plutus fixtures, and the pinned constant itself. The generator was the sole outlier. Correct the path, and name the master key, path and destination as constants instead of inlining them, so a test can assert they derive to the pinned golden vector. The drift was possible because two files held the same value independently with nothing comparing them. --- .../golden_vector_test.go | 43 +++++++++++++++++++ scripts/mpc-finalization-evidence/main.go | 36 ++++++++++------ 2 files changed, 67 insertions(+), 12 deletions(-) create mode 100644 scripts/mpc-finalization-evidence/golden_vector_test.go diff --git a/scripts/mpc-finalization-evidence/golden_vector_test.go b/scripts/mpc-finalization-evidence/golden_vector_test.go new file mode 100644 index 0000000..cf35218 --- /dev/null +++ b/scripts/mpc-finalization-evidence/golden_vector_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/hex" + "testing" + + "proof-tool/internal/circuit/ownership" + "proof-tool/internal/circuit/ownershipdest" + "proof-tool/internal/mpcceremony" +) + +// TestGeneratedVectorMatchesPinnedGolden pins the relationship this command +// depends on and that nothing else checked. +// +// The generator derives a credential from a hardcoded master key at a hardcoded +// path, while PublicFinalizationEvidence.Validate accepts only the credential +// pinned in mpcceremony.GoldenPublicCredentialHex. Those are two independent +// constants that must agree. They did not: the generator derived at account 3, +// role 2 while the pinned credential is account 0, role 0, so every attempt to +// finalize a ceremony failed with "public evidence does not use the exact +// repository golden public vector" — after the multi-hour replay that produces +// the keys, which is the only point at which it is reachable. +func TestGeneratedVectorMatchesPinnedGolden(t *testing.T) { + master, err := ownership.DecodeMasterXPrvHex(goldenMasterXPrvHex) + if err != nil { + t.Fatal(err) + } + credential, err := ownership.DeriveCredential(master, goldenPath) + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(credential[:]); got != mpcceremony.GoldenPublicCredentialHex { + t.Fatalf("derived credential %s, pinned golden %s", got, mpcceremony.GoldenPublicCredentialHex) + } + + destination, err := ownershipdest.DecodeDestinationAddressV1Hex(goldenDestinationHex) + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(destination); got != mpcceremony.GoldenPublicDestinationHex { + t.Fatalf("destination %s, pinned golden %s", got, mpcceremony.GoldenPublicDestinationHex) + } +} diff --git a/scripts/mpc-finalization-evidence/main.go b/scripts/mpc-finalization-evidence/main.go index cde6468..e702336 100644 --- a/scripts/mpc-finalization-evidence/main.go +++ b/scripts/mpc-finalization-evidence/main.go @@ -22,6 +22,26 @@ import ( const resultSchema = "proof-tool-mpc-public-evidence-generation-result-v1" +// The repository's public golden test vector. This is not user wallet +// material; keeping it in this separate helper is what proves the +// participant and coordinator binary never handles a wallet secret. +// +// These must derive to mpcceremony.GoldenPublicCredentialHex and equal +// mpcceremony.GoldenPublicDestinationHex, because +// PublicFinalizationEvidence.Validate accepts nothing else. They are named +// here rather than inlined so golden_vector_test.go can assert that +// agreement; when they were inlined the path drifted from the pinned +// credential and finalization became unreachable. +const ( + goldenMasterXPrvHex = "c065afd2832cd8b087c4d9ab7011f481ee1e0721e78ea5dd609f3ab3f156d245" + + "d176bd8fd4ec60b4731c3918a2a72a0226c0cd119ec35b47e4d55884667f552a" + + "23f7fdcd4a10c6cd2c7393ac61d877873e248f417634aa3d812af327ffe9d620" + goldenDestinationHex = "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111000000" + + "0000000000000000000000000000000000000000000000000000" +) + +var goldenPath = ownership.Path{Account: 0, Role: 0, Index: 0} + func main() { if err := run(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) @@ -70,23 +90,15 @@ func run() error { // This is the repository's public golden test witness, not user wallet // material. Keeping it in this separate rehearsal helper proves that the // participant/coordinator ceremony binary never handles a wallet secret. - master, err := ownership.DecodeMasterXPrvHex( - "c065afd2832cd8b087c4d9ab7011f481ee1e0721e78ea5dd609f3ab3f156d245" + - "d176bd8fd4ec60b4731c3918a2a72a0226c0cd119ec35b47e4d55884667f552a" + - "23f7fdcd4a10c6cd2c7393ac61d877873e248f417634aa3d812af327ffe9d620", - ) + master, err := ownership.DecodeMasterXPrvHex(goldenMasterXPrvHex) if err != nil { return err } - destination, err := ownershipdest.DecodeDestinationAddressV1Hex( - "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111000000" + - "0000000000000000000000000000000000000000000000000000", - ) + destination, err := ownershipdest.DecodeDestinationAddressV1Hex(goldenDestinationHex) if err != nil { return err } - path := ownership.Path{Account: 3, Role: 2, Index: 0} - credential, err := ownership.DeriveCredential(master, path) + credential, err := ownership.DeriveCredential(master, goldenPath) if err != nil { return err } @@ -98,7 +110,7 @@ func run() error { if err != nil { return err } - assignment, err := ownershipdest.Assignment(master, path, destination, publicInput) + assignment, err := ownershipdest.Assignment(master, goldenPath, destination, publicInput) if err != nil { return err } From c4d4dc59698b2f9600b5ce45c9cabb294912857b Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 08:16:38 +0000 Subject: [PATCH 15/42] Redact diagnostics by construction, matching short values per token Two of the three gaps recorded for the CLI redaction blocklist: writeDiagnostic previously performed no redaction, so only the error paths that remembered to call redactCLIError were covered and a new diagnostic call site could echo a command-line value silently. writeDiagnostic now takes argv and redacts the formatted message itself; there is no unredacted stderr outlet left to forget. Redaction is idempotent, so already-redacted messages pass through unchanged. Short argument values previously blanked matching substrings of unrelated numbers and words (a participant count of 3 blanked every digit 3 in the message). Values shorter than four characters are now replaced only as whole tokens. A plain length floor was tried before and reverted because validateID permits one-character key ids and skipping them entirely leaked the id verbatim; token matching keeps those redacted while leaving longer tokens that merely contain the short value readable. --- cmd/mpc-ceremony/main.go | 90 ++++++++++++++++++++++++------ cmd/mpc-ceremony/redaction_test.go | 77 +++++++++++++++++++++++++ 2 files changed, 150 insertions(+), 17 deletions(-) create mode 100644 cmd/mpc-ceremony/redaction_test.go diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 0ea52ee..cc80991 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -30,7 +30,7 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut var help *helpRequest if errors.As(err, &help) { if err := writeUsage(stdout, help.topic); err != nil { - writeDiagnostic(stderr, "error: write help: %v\n", err) + writeDiagnostic(stderr, args, "error: write help: %v\n", err) return 6 } return 0 @@ -39,17 +39,15 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut if errors.As(err, &usage) { message := redactCLIError(usage.message, args) if requestsJSON(args) { - return writeParseError(message, stdout, stderr) - } - if _, err := fmt.Fprintf(stderr, "error: %s\n\n", message); err != nil { - return 6 + return writeParseError(message, args, stdout, stderr) } + writeDiagnostic(stderr, args, "error: %s\n\n", message) if err := writeUsage(stderr, usage.topic); err != nil { return 6 } return 2 } - writeDiagnostic(stderr, "error: %s\n", redactCLIError(err.Error(), args)) + writeDiagnostic(stderr, args, "error: %s\n", err.Error()) return 6 } @@ -65,19 +63,19 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut result.Command = invocation.Command if invocation.Global.Format == "json" { if err := json.NewEncoder(stdout).Encode(result); err != nil { - writeDiagnostic(stderr, "error: encode command result: %v\n", err) + writeDiagnostic(stderr, args, "error: encode command result: %v\n", err) return 6 } return 0 } if result.Summary != "" { if _, err := fmt.Fprintln(stdout, result.Summary); err != nil { - writeDiagnostic(stderr, "error: write command result: %v\n", err) + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) return 6 } } else { if _, err := fmt.Fprintf(stdout, "%s completed\n", invocation.Command); err != nil { - writeDiagnostic(stderr, "error: write command result: %v\n", err) + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) return 6 } } @@ -89,7 +87,7 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut for _, name := range names { path := result.Outputs[name] if _, err := fmt.Fprintf(stdout, "%s: %s\n", name, path); err != nil { - writeDiagnostic(stderr, "error: write command result: %v\n", err) + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) return 6 } } @@ -120,11 +118,11 @@ func writeExecutionError(invocation Invocation, err error, args []string, stdout payload.Error.Code = code payload.Error.Message = message if encodeErr := json.NewEncoder(stdout).Encode(payload); encodeErr != nil { - writeDiagnostic(stderr, "error: encode command error: %v\n", encodeErr) + writeDiagnostic(stderr, args, "error: encode command error: %v\n", encodeErr) } return exitCode } - writeDiagnostic(stderr, "error: %s\n", message) + writeDiagnostic(stderr, args, "error: %s\n", message) return exitCode } @@ -161,11 +159,65 @@ func redactCLIError(message string, args []string) string { return len(ordered[i]) > len(ordered[j]) }) for _, candidate := range ordered { - message = strings.ReplaceAll(message, candidate, redactedCLIValue) + message = redactCandidate(message, candidate) } return message } +// shortCandidateLength is the length below which redaction switches from +// substring replacement to whole-token replacement. Long values are replaced +// wherever they appear: incidental collisions are vanishingly rare and a +// secret embedded in a longer string must still be caught. Short values are +// replaced only as complete tokens: a one-to-three character argument such as +// a participant count would otherwise blank matching digits and letters inside +// unrelated words, degrading the diagnostic exactly when it is needed. A short +// value that IS echoed verbatim — validateID permits one-character key ids — +// still appears as its own token and is still redacted, which is the leak that +// forced the revert of the plain length-floor approach. +const shortCandidateLength = 4 + +func redactCandidate(message, candidate string) string { + if len(candidate) >= shortCandidateLength { + return strings.ReplaceAll(message, candidate, redactedCLIValue) + } + var builder strings.Builder + remaining := message + for { + index := strings.Index(remaining, candidate) + if index < 0 { + builder.WriteString(remaining) + return builder.String() + } + before := remaining[:index] + after := remaining[index+len(candidate):] + if isTokenBoundary(before, true) && isTokenBoundary(after, false) { + builder.WriteString(before) + builder.WriteString(redactedCLIValue) + remaining = after + continue + } + builder.WriteString(remaining[:index+len(candidate)]) + remaining = after + } +} + +// isTokenBoundary reports whether the text adjacent to a candidate ends (or +// starts) a token: empty, or a byte that cannot continue an identifier or +// number. Letters and digits continue a token; everything else separates. +func isTokenBoundary(adjacent string, atEnd bool) bool { + if adjacent == "" { + return true + } + var b byte + if atEnd { + b = adjacent[len(adjacent)-1] + } else { + b = adjacent[0] + } + isAlphanumeric := b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' + return !isAlphanumeric +} + func identifyCLICommandArguments(args []string) map[int]struct{} { safe := make(map[int]struct{}) index := 0 @@ -224,8 +276,12 @@ func addCLIErrorCandidate(candidates map[string]struct{}, value string) { candidates[value] = struct{}{} } -func writeDiagnostic(w io.Writer, format string, args ...any) { - _, _ = fmt.Fprintf(w, format, args...) +// writeDiagnostic is the only stderr outlet. It redacts the formatted message +// against argv by construction, so a new diagnostic call site cannot leak a +// command-line value by forgetting to call redactCLIError first. Call sites +// that already redacted are unaffected: redaction is idempotent. +func writeDiagnostic(w io.Writer, cliArgs []string, format string, args ...any) { + _, _ = fmt.Fprint(w, redactCLIError(fmt.Sprintf(format, args...), cliArgs)) } func requestsJSON(args []string) bool { @@ -242,7 +298,7 @@ func requestsJSON(args []string) bool { return false } -func writeParseError(message string, stdout, stderr io.Writer) int { +func writeParseError(message string, args []string, stdout, stderr io.Writer) int { payload := struct { Schema string `json:"schema"` OK bool `json:"ok"` @@ -257,7 +313,7 @@ func writeParseError(message string, stdout, stderr io.Writer) int { payload.Error.Code = "usage_error" payload.Error.Message = message if err := json.NewEncoder(stdout).Encode(payload); err != nil { - writeDiagnostic(stderr, "error: encode usage error: %v\n", err) + writeDiagnostic(stderr, args, "error: encode usage error: %v\n", err) return 6 } return 2 diff --git a/cmd/mpc-ceremony/redaction_test.go b/cmd/mpc-ceremony/redaction_test.go new file mode 100644 index 0000000..626f24f --- /dev/null +++ b/cmd/mpc-ceremony/redaction_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRedactShortValuesOnlyAsWholeTokens(t *testing.T) { + t.Parallel() + + // A short argument value must not blank matching characters inside + // unrelated numbers and words. + args := []string{"phase1", "verify", "--candidate-dir", "3"} + message := "chain has 3 records at index 13 under /tmp/3/state" + redacted := redactCLIError(message, args) + if strings.Contains(redacted, "index 1"+redactedCLIValue) { + t.Fatalf("digit inside a larger number was blanked: %q", redacted) + } + if !strings.Contains(redacted, "index 13") { + t.Fatalf("unrelated number damaged: %q", redacted) + } + if !strings.Contains(redacted, "has "+redactedCLIValue+" records") { + t.Fatalf("standalone short value survived: %q", redacted) + } + if !strings.Contains(redacted, "/tmp/"+redactedCLIValue+"/state") { + t.Fatalf("short path segment survived: %q", redacted) + } +} + +func TestRedactShortKeyIDStillRedactedAsToken(t *testing.T) { + t.Parallel() + + // validateID permits identifiers as short as one character. A short key + // id echoed verbatim appears as its own token and must still be redacted; + // this is the leak that forced reverting the plain length-floor approach. + args := []string{"phase1", "verify", "--participant", "p3"} + redacted := redactCLIError(`participant "p3" is not in the signed roster`, args) + if strings.Contains(redacted, "p3") { + t.Fatalf("short identifier leaked: %q", redacted) + } + // The same short value inside a longer token is a different token and + // stays readable. + other := redactCLIError("participant p30 is not scheduled", args) + if !strings.Contains(other, "p30") { + t.Fatalf("longer identifier containing the short value was damaged: %q", other) + } +} + +func TestRedactLongValuesAnywhere(t *testing.T) { + t.Parallel() + + args := []string{"phase1", "verify", "--key", "SENSITIVE-VALUE"} + redacted := redactCLIError("open /keys/SENSITIVE-VALUE.hex failed", args) + if strings.Contains(redacted, "SENSITIVE-VALUE") { + t.Fatalf("long value leaked as substring: %q", redacted) + } +} + +func TestWriteDiagnosticRedactsByConstruction(t *testing.T) { + t.Parallel() + + // A diagnostic call site that never called redactCLIError must still not + // echo a caller-supplied value. + args := []string{"phase1", "verify", "--key", "SENSITIVE-SENTINEL"} + var out bytes.Buffer + writeDiagnostic(&out, args, "error: open %s: no such file\n", "SENSITIVE-SENTINEL") + if strings.Contains(out.String(), "SENSITIVE-SENTINEL") { + t.Fatalf("writeDiagnostic leaked an argument value: %q", out.String()) + } + if !strings.Contains(out.String(), redactedCLIValue) { + t.Fatalf("writeDiagnostic did not mark the redaction: %q", out.String()) + } +} From 612ce7445689ce18f99d652ab297b4e1abff659d Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 08:16:53 +0000 Subject: [PATCH 16/42] Add a read-only inspect command for recovery state The failover drill instructs the operator to run a read-only inspection and compare the derived next participant and index with the primary run card, but no such command existed; the only "inspect" was a rehearsal-script stage reading its own step markers rather than the signed chain. mpc-ceremony inspect reports ceremony identity and mode, per-phase accepted count and head record, the next scheduled participant and index (a pure function of the signed chain and the frozen policy order), closure, beacon, and seal state, and which referenced artifacts are present. It requires no signing key, writes nothing, and never replays contributions. Two depths, and the output states which one ran: the default verifies signatures and structure and checks artifact presence by size in seconds; --full additionally re-verifies every payload digest, attestation, erasure, and coordinator verification record through the same loaders the operational commands use. Neither depth re-runs the gnark replay; that remains the job of contribute, verify, and audit. Unlike every other command, inspect discovers the highest published chain file per phase. That is safe only because inspection is read-only: its output feeds no signing or verification decision, every discovered file is authenticated against the out-of-band trust anchor before being reported, and the chain filename index must equal the signed record count. The workflow helper exercises both depths at end of lifecycle from a real built binary so the running-software gate is the production gate. --- cmd/mpc-ceremony/executor.go | 62 +++ cmd/mpc-ceremony/main.go | 2 +- cmd/mpc-ceremony/parse.go | 21 + cmd/mpc-ceremony/types.go | 10 + cmd/mpc-ceremony/usage.go | 22 ++ internal/mpcceremony/inspect.go | 362 ++++++++++++++++++ internal/mpcceremony/inspect_test.go | 83 ++++ .../testdata/workflowhelper/main.go | 42 ++ 8 files changed, 603 insertions(+), 1 deletion(-) create mode 100644 internal/mpcceremony/inspect.go create mode 100644 internal/mpcceremony/inspect_test.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 6febf74..9b1d708 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -31,6 +31,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com switch invocation.Command { case CommandInit: return executeInit(invocation.Options.(InitOptions)) + case CommandInspect: + return executeInspect(invocation.Options.(InspectOptions)) case CommandPhase1Contribute: return executeContribution(mpcceremony.Phase1, invocation.Options.(ContributeOptions)) case CommandPhase1Erasure: @@ -824,3 +826,63 @@ func loadOperationalCircuit(paths mpcceremony.TrustPaths, transcriptRoot string) r1csPath := filepath.Join(transcriptRoot, filepath.FromSlash(trusted.Definition.Circuit.R1CS.Name)) return mpcceremony.ReadR1CSFile(r1csPath, trusted.Definition.Circuit) } + +func executeInspect(options InspectOptions) (CommandResult, error) { + result, err := mpcceremony.InspectCeremony(mpcceremony.InspectCeremonyOptions{ + Trust: trustPaths( + options.CeremonyPath, + options.CeremonySignaturePath, + options.CoordinatorPublicKeyFile, + ), + TranscriptRoot: options.TranscriptDir, + Full: options.Full, + }) + if err != nil { + return CommandResult{}, err + } + outputs := map[string]string{ + "mode": result.Mode, + "depth": result.Depth, + } + for _, phase := range result.Phases { + prefix := string(phase.Phase) + if !phase.Started { + outputs[prefix+"_status"] = "not started" + continue + } + status := "accepting contributions" + switch { + case phase.Sealed: + status = "sealed" + case phase.BeaconRecorded: + status = "beacon recorded" + case phase.Closed: + status = "closed" + case phase.ContributionsComplete: + status = "contributions complete" + } + outputs[prefix+"_status"] = status + outputs[prefix+"_chain"] = phase.ChainFile + outputs[prefix+"_accepted"] = fmt.Sprintf("%d of %d scheduled", phase.AcceptedCount, phase.ScheduledTotal) + outputs[prefix+"_head_record_id"] = phase.HeadRecordID + outputs[prefix+"_head_payload"] = phase.HeadPayload + if phase.NextParticipantID != "" { + outputs[prefix+"_next_contribution"] = fmt.Sprintf( + "index %d by %s", phase.NextIndex, phase.NextParticipantID, + ) + } + if len(phase.MissingArtifacts) == 0 { + outputs[prefix+"_artifacts"] = "all referenced artifacts present" + } else { + outputs[prefix+"_artifacts"] = "MISSING: " + strings.Join(phase.MissingArtifacts, "; ") + } + } + return CommandResult{ + CeremonyID: result.CeremonyID, + Summary: fmt.Sprintf( + "inspected ceremony at %s depth; inspection is read-only and authorizes nothing", + result.Depth, + ), + Outputs: outputs, + }, nil +} diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index cc80991..4220212 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -239,7 +239,7 @@ func identifyCLICommandArguments(args []string) map[int]struct{} { command: topLevel := map[string]struct{}{ - "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, + "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "release": {}, } if _, ok := topLevel[args[index]]; !ok { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index cdc108f..f3ebcfd 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -61,6 +61,10 @@ func parseInvocation(args []string) (Invocation, error) { options, err := parseInit(rest[1:]) invocation.Command, invocation.Options = CommandInit, options return invocation, wrapCommandError(err, "init") + case "inspect": + options, err := parseInspect(rest[1:]) + invocation.Command, invocation.Options = CommandInspect, options + return invocation, wrapCommandError(err, "inspect") case "phase1": return parsePhase1(invocation, rest[1:]) case "phase2": @@ -979,3 +983,20 @@ func (s *stringList) Set(value string) error { *s = append(*s, value) return nil } + +func parseInspect(args []string) (InspectOptions, error) { + var options InspectOptions + fs := commandFlagSet("inspect") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.TranscriptDir, "transcript-dir", "", "ceremony transcript root directory") + fs.BoolVar(&options.Full, "full", false, "re-verify every chain record and artifact digest instead of metadata only") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-dir", options.TranscriptDir), + ) +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 9da5949..29a636e 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -14,6 +14,7 @@ type Command string const ( CommandInit Command = "init" + CommandInspect Command = "inspect" CommandPhase1Contribute Command = "phase1 contribute" CommandPhase1Erasure Command = "phase1 attest-erasure" CommandPhase1Verify Command = "phase1 verify" @@ -327,3 +328,12 @@ type unwiredExecutor struct{} func (unwiredExecutor) Execute(context.Context, Invocation) (CommandResult, error) { return CommandResult{}, errExecutorNotWired } + +// InspectOptions configures the read-only ceremony inspection command. +type InspectOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + TranscriptDir string + Full bool +} diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 56f4e7b..c6197f1 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -28,6 +28,7 @@ It performs no network access and never selects a mutable "latest" artifact. Commands: init Bind a ceremony to the compiled repository circuit + inspect Report chain state and next scheduled contribution phase1 contribute Verify the full phase 1 chain and contribute phase1 attest-erasure Sign a participant destruction attestation phase1 verify Verify and append one candidate contribution @@ -59,6 +60,26 @@ verification-bypass flags. Run "mpc-ceremony help " for command-specific help. ` +var inspectHelp = `Usage: + mpc-ceremony inspect --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-dir DIR [--full] + +Read-only recovery inspection. Reports ceremony identity and mode, per-phase +accepted count and head record, the next scheduled participant and index, the +closure/beacon/seal state, and which referenced artifacts are present. + +It requires no signing key, writes nothing, and never replays contributions. +Unlike every other command it discovers the highest published chain file per +phase; that is safe only because the result feeds no signing or verification +decision, and every discovered file is authenticated against the trust anchor +before being reported. + +The default depth verifies signatures and structure and checks artifact +presence by size in seconds. --full additionally re-verifies every payload +digest, attestation, erasure, and verification record, which re-hashes every +artifact. The output states which depth ran. +` + const replayFlagsHelp = ` Required immutable replay evidence: --transcript-root DIR @@ -78,6 +99,7 @@ second path list. ` var commandHelp = map[string]string{ + "inspect": inspectHelp, "init": `Usage: mpc-ceremony init --key-version ownership-destination-v2 \ --participants ROSTER.json --policy POLICY.json \ diff --git a/internal/mpcceremony/inspect.go b/internal/mpcceremony/inspect.go new file mode 100644 index 0000000..56a027b --- /dev/null +++ b/internal/mpcceremony/inspect.go @@ -0,0 +1,362 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// InspectDepthMetadata verifies signatures and structure only: the signed +// definition, the highest published chain per phase, and closure, beacon, and +// seal records when present. Artifact presence is checked by name and size; +// no artifact bytes are hashed and nothing is replayed. Seconds at any K. +const InspectDepthMetadata = "metadata" + +// InspectDepthFull additionally re-verifies every chain file the way the +// operational commands do: every payload digest, attestation, erasure, and +// coordinator verification record. It does not re-run the gnark replay; that +// remains the job of contribute, verify, and audit. +const InspectDepthFull = "full" + +// InspectCeremonyOptions configures the read-only ceremony inspection. +type InspectCeremonyOptions struct { + Trust TrustPaths + TranscriptRoot string + // Full selects InspectDepthFull. The default is InspectDepthMetadata. + Full bool +} + +// PhaseInspection reports the recovered state of one phase. +type PhaseInspection struct { + Phase Phase + Started bool + // ChainFile is the transcript-relative chain that was inspected: the + // highest index for which both the chain and its signature exist. The + // filename index must equal the record count, so a stale or renamed + // chain cannot claim another position. + ChainFile string + AcceptedCount int + // ScheduledTotal is the frozen participant schedule length. + ScheduledTotal int + HeadRecordID string + HeadPayload string + // NextIndex and NextParticipantID name the only contribution the frozen + // order permits next; both are zero values once every scheduled + // participant has been accepted. + NextIndex int + NextParticipantID string + ContributionsComplete bool + Closed bool + BeaconRecorded bool + Sealed bool + // MissingArtifacts lists referenced artifacts that are absent or have the + // wrong size. Empty means every referenced artifact is present. + MissingArtifacts []string +} + +// InspectResult is the full read-only inspection report. +type InspectResult struct { + CeremonyID string + Mode string + Depth string + Phases []PhaseInspection +} + +// InspectCeremony reports ceremony state from already-signed data: ceremony +// identity and mode, per-phase accepted count and head, the next scheduled +// participant, and which referenced artifacts are present. It requires no +// signing key, writes nothing, and never replays contributions. +// +// Unlike every other command, inspect discovers the highest published chain +// file per phase instead of taking explicit chain paths. That discovery is +// safe here and only here because inspection is read-only: its output feeds +// no signing or verification decision, every discovered file is still +// authenticated against the out-of-band trust anchor before being reported, +// and the chain filename index must match the signed record count. +func InspectCeremony(options InspectCeremonyOptions) (InspectResult, error) { + var result InspectResult + trusted, err := loadOperationalCeremony(options.Trust) + if err != nil { + return result, err + } + if strings.TrimSpace(options.TranscriptRoot) == "" { + return result, errors.New("transcript root is required") + } + result.CeremonyID = trusted.Definition.CeremonyID + result.Mode = trusted.Definition.Mode + result.Depth = InspectDepthMetadata + if options.Full { + result.Depth = InspectDepthFull + } + + var circuit *CompiledCircuit + if options.Full { + r1csPath := filepath.Join( + options.TranscriptRoot, + filepath.FromSlash(trusted.Definition.Circuit.R1CS.Name), + ) + circuit, err = ReadR1CSFile(r1csPath, trusted.Definition.Circuit) + if err != nil { + return result, fmt.Errorf("full inspection requires the pinned R1CS: %w", err) + } + } + + phase1, phase1Seal, err := inspectPhase(trusted, circuit, options, Phase1, nil) + if err != nil { + return result, fmt.Errorf("inspect phase1: %w", err) + } + result.Phases = append(result.Phases, phase1) + + phase2, _, err := inspectPhase(trusted, circuit, options, Phase2, phase1Seal) + if err != nil { + return result, fmt.Errorf("inspect phase2: %w", err) + } + result.Phases = append(result.Phases, phase2) + return result, nil +} + +func inspectPhase( + trusted *TrustedCeremony, + circuit *CompiledCircuit, + options InspectCeremonyOptions, + phase Phase, + phase1Seal *SealRecord, +) (PhaseInspection, *SealRecord, error) { + inspection := PhaseInspection{Phase: phase} + phaseDir := filepath.Join(options.TranscriptRoot, string(phase)) + if _, err := os.Lstat(phaseDir); errors.Is(err, fs.ErrNotExist) { + return inspection, nil, nil + } else if err != nil { + return inspection, nil, fmt.Errorf("inspect phase directory: %w", err) + } + + chainIndex := -1 + var chainPath, chainSignaturePath string + for index := 0; index <= MaxParticipants; index++ { + candidate := filepath.Join(phaseDir, fmt.Sprintf("chain-%04d.json", index)) + signature := DefaultSignaturePath(candidate) + if fileExists(candidate) && fileExists(signature) { + chainIndex = index + chainPath, chainSignaturePath = candidate, signature + } + } + if chainIndex < 0 { + return inspection, nil, nil + } + inspection.Started = true + inspection.ChainFile = filepath.ToSlash( + filepath.Join(string(phase), fmt.Sprintf("chain-%04d.json", chainIndex)), + ) + + chain, err := LoadSignedChain(trusted, PhaseTranscriptPaths{ + RootDir: options.TranscriptRoot, + ChainPath: chainPath, + ChainSignaturePath: chainSignaturePath, + }) + if err != nil { + return inspection, nil, fmt.Errorf("chain %s: %w", inspection.ChainFile, err) + } + if chain.Phase != phase { + return inspection, nil, fmt.Errorf("chain %s is for phase %q", inspection.ChainFile, chain.Phase) + } + if len(chain.Records) != chainIndex { + return inspection, nil, fmt.Errorf( + "chain %s holds %d records; the filename index requires exactly %d", + inspection.ChainFile, len(chain.Records), chainIndex, + ) + } + + policy, err := trusted.Definition.PolicyForPhase(phase) + if err != nil { + return inspection, nil, err + } + inspection.AcceptedCount = len(chain.Records) + inspection.ScheduledTotal = len(policy.Participants) + headID, err := chain.HeadRecordID() + if err != nil { + return inspection, nil, err + } + headPayload, err := chain.HeadPayload() + if err != nil { + return inspection, nil, err + } + inspection.HeadRecordID = headID + inspection.HeadPayload = headPayload.Name + if len(chain.Records) < len(policy.Participants) { + inspection.NextIndex = len(chain.Records) + 1 + inspection.NextParticipantID = policy.Participants[len(chain.Records)] + } else { + inspection.ContributionsComplete = true + } + + inspection.MissingArtifacts = missingChainArtifacts(options.TranscriptRoot, chain) + + closeRecord, closed, err := inspectCloseRecord(trusted, phaseDir, chain) + if err != nil { + return inspection, nil, err + } + inspection.Closed = closed + + var beacon BeaconRecord + if closed { + beaconRecorded, err := inspectBeaconRecord(trusted, phaseDir, closeRecord, &beacon) + if err != nil { + return inspection, nil, err + } + inspection.BeaconRecorded = beaconRecorded + } + + var seal *SealRecord + if phase == Phase1 && inspection.BeaconRecorded { + sealed, loadedSeal, err := inspectSealRecord(trusted, options.TranscriptRoot, closeRecord, beacon) + if err != nil { + return inspection, nil, err + } + inspection.Sealed = sealed + seal = loadedSeal + } + + if options.Full { + if err := inspectFullDepth(trusted, circuit, options.TranscriptRoot, phase, chainPath, chainSignaturePath, phase1Seal); err != nil { + return inspection, nil, fmt.Errorf("full verification of %s: %w", inspection.ChainFile, err) + } + } + return inspection, seal, nil +} + +func inspectCloseRecord(trusted *TrustedCeremony, phaseDir string, chain Chain) (CloseRecord, bool, error) { + closePath := filepath.Join(phaseDir, closePublicationDirectoryName, closeRecordFilename) + closeSignature := filepath.Join(phaseDir, closePublicationDirectoryName, closeSignatureFilename) + if !fileExists(closePath) || !fileExists(closeSignature) { + return CloseRecord{}, false, nil + } + var closeRecord CloseRecord + if err := loadCoordinatorSignedRecord(trusted, closePath, closeSignature, &closeRecord); err != nil { + return CloseRecord{}, false, fmt.Errorf("closure record: %w", err) + } + if err := ValidateClose(trusted.Definition, chain, closeRecord); err != nil { + return CloseRecord{}, false, fmt.Errorf("closure record: %w", err) + } + return closeRecord, true, nil +} + +func inspectBeaconRecord( + trusted *TrustedCeremony, + phaseDir string, + closeRecord CloseRecord, + beacon *BeaconRecord, +) (bool, error) { + beaconPath := filepath.Join(phaseDir, "beacon", "record.json") + beaconSignature := filepath.Join(phaseDir, "beacon", "record.sig") + if !fileExists(beaconPath) || !fileExists(beaconSignature) { + return false, nil + } + if err := loadCoordinatorSignedRecord(trusted, beaconPath, beaconSignature, beacon); err != nil { + return false, fmt.Errorf("beacon record: %w", err) + } + if err := ValidateBeacon(trusted.Definition, closeRecord, *beacon); err != nil { + return false, fmt.Errorf("beacon record: %w", err) + } + return true, nil +} + +func inspectSealRecord( + trusted *TrustedCeremony, + transcriptRoot string, + closeRecord CloseRecord, + beacon BeaconRecord, +) (bool, *SealRecord, error) { + sealPath := filepath.Join(transcriptRoot, string(Phase1), "sealed", "seal.json") + sealSignature := filepath.Join(transcriptRoot, string(Phase1), "sealed", "seal.sig") + if !fileExists(sealPath) || !fileExists(sealSignature) { + return false, nil, nil + } + var seal SealRecord + if err := loadCoordinatorSignedRecord(trusted, sealPath, sealSignature, &seal); err != nil { + return false, nil, fmt.Errorf("seal record: %w", err) + } + if err := ValidateSeal(closeRecord, beacon, seal); err != nil { + return false, nil, fmt.Errorf("seal record: %w", err) + } + return true, &seal, nil +} + +// missingChainArtifacts reports referenced artifacts that are absent or whose +// size disagrees with the signed reference. Size is a presence check, not an +// integrity check: full depth re-hashes contents, metadata depth does not. +func missingChainArtifacts(root string, chain Chain) []string { + var missing []string + references := make([]ArtifactRef, 0, len(chain.Records)+1) + if chain.Phase == Phase1 { + references = append(references, chain.Genesis) + } + for _, record := range chain.Records { + references = append(references, record.OutputPayload) + } + for _, ref := range references { + path, err := resolveArtifactPath(root, ref.Name) + if err != nil { + missing = append(missing, fmt.Sprintf("%s (unresolvable: %v)", ref.Name, err)) + continue + } + info, err := os.Lstat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + missing = append(missing, ref.Name) + case err != nil: + missing = append(missing, fmt.Sprintf("%s (unreadable: %v)", ref.Name, err)) + case info.Size() != ref.Digest.Size: + missing = append(missing, fmt.Sprintf( + "%s (size %d, signed reference requires %d)", + ref.Name, info.Size(), ref.Digest.Size, + )) + } + } + return missing +} + +func inspectFullDepth( + trusted *TrustedCeremony, + circuit *CompiledCircuit, + transcriptRoot string, + phase Phase, + chainPath, chainSignaturePath string, + phase1Seal *SealRecord, +) error { + paths := PhaseTranscriptPaths{ + RootDir: transcriptRoot, + ChainPath: chainPath, + ChainSignaturePath: chainSignaturePath, + } + if phase == Phase1 { + _, err := loadVerifiedPhase1Files(trusted, circuit, paths) + return err + } + if phase1Seal == nil { + return errors.New("phase2 full verification requires the verified phase1 seal") + } + sealPath := filepath.Join(transcriptRoot, string(Phase1), "sealed", "seal.json") + commons, seal, _, err := loadPhase1CommonsForPhase2( + trusted, + circuit, + transcriptRoot, + sealPath, + DefaultSignaturePath(sealPath), + ) + if err != nil { + return err + } + _, err = loadVerifiedPhase2Files(trusted, circuit, commons, seal, paths) + return err +} + +func fileExists(path string) bool { + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() +} diff --git a/internal/mpcceremony/inspect_test.go b/internal/mpcceremony/inspect_test.go new file mode 100644 index 0000000..3d9b4ef --- /dev/null +++ b/internal/mpcceremony/inspect_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func inspectTestRef(name string, size int64) ArtifactRef { + return ArtifactRef{ + Name: name, + Digest: Digest{ + SHA256: "sha256:" + strings.Repeat("ab", 32), + Blake2b256: "blake2b256:" + strings.Repeat("cd", 32), + Size: size, + }, + } +} + +func TestMissingChainArtifactsReportsAbsentAndWrongSize(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "phase1"), 0o700); err != nil { + t.Fatal(err) + } + present := filepath.Join(root, "phase1", "genesis.bin") + if err := os.WriteFile(present, []byte("12345678"), 0o600); err != nil { + t.Fatal(err) + } + truncated := filepath.Join(root, "phase1", "contribution-0001.bin") + if err := os.WriteFile(truncated, []byte("123"), 0o600); err != nil { + t.Fatal(err) + } + + chain := Chain{ + Phase: Phase1, + Genesis: inspectTestRef("phase1/genesis.bin", 8), + Records: []ChainRecord{ + {OutputPayload: inspectTestRef("phase1/contribution-0001.bin", 8)}, + {OutputPayload: inspectTestRef("phase1/contribution-0002.bin", 8)}, + }, + } + missing := missingChainArtifacts(root, chain) + if len(missing) != 2 { + t.Fatalf("missing = %v, want wrong-size and absent entries", missing) + } + if !strings.Contains(missing[0], "contribution-0001.bin") || !strings.Contains(missing[0], "size 3") { + t.Fatalf("wrong-size entry = %q", missing[0]) + } + if missing[1] != "phase1/contribution-0002.bin" { + t.Fatalf("absent entry = %q", missing[1]) + } +} + +func TestMissingChainArtifactsRejectsEscapingNames(t *testing.T) { + t.Parallel() + chain := Chain{ + Phase: Phase2, + Records: []ChainRecord{{OutputPayload: inspectTestRef("../outside.bin", 8)}}, + } + missing := missingChainArtifacts(t.TempDir(), chain) + if len(missing) != 1 || !strings.Contains(missing[0], "unresolvable") { + t.Fatalf("missing = %v, want one unresolvable entry", missing) + } +} + +func TestInspectCeremonyRequiresTranscriptRoot(t *testing.T) { + t.Parallel() + _, err := InspectCeremony(InspectCeremonyOptions{ + Trust: TrustPaths{ + DefinitionPath: "ceremony.json", + DefinitionSignaturePath: "ceremony.sig", + CoordinatorPublicKeyPath: "coordinator.hex", + }, + }) + if err == nil { + t.Fatal("inspect accepted an empty transcript root") + } +} diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index e6a67f3..84367af 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -617,6 +617,48 @@ func run(outputRoot, operationalEvidenceHelper string) error { Phase2BeaconPath: phase2Beacon.BeaconPath, Phase2BeaconSignaturePath: phase2Beacon.SignaturePath, } + // Both phases are complete, closed, beaconed, and phase 1 is sealed. + // Exercise the read-only inspection at both depths from inside the same + // binary that ran init, so the running-software gate verifies a real + // executable identity exactly as it does for every other command. + for _, full := range []bool{false, true} { + inspection, err := mpcceremony.InspectCeremony(mpcceremony.InspectCeremonyOptions{ + Trust: mpcceremony.TrustPaths{ + DefinitionPath: initialized.DefinitionPath, + DefinitionSignaturePath: initialized.DefinitionSignaturePath, + CoordinatorPublicKeyPath: trustedCoordinatorPath, + }, + TranscriptRoot: ceremonyRoot, + Full: full, + }) + if err != nil { + return fmt.Errorf("inspect ceremony (full=%v): %w", full, err) + } + if inspection.CeremonyID != initialized.Definition.CeremonyID { + return fmt.Errorf("inspect ceremony id %q, want %q", inspection.CeremonyID, initialized.Definition.CeremonyID) + } + if len(inspection.Phases) != 2 { + return fmt.Errorf("inspect reported %d phases, want 2", len(inspection.Phases)) + } + for _, phase := range inspection.Phases { + if !phase.Started || !phase.ContributionsComplete || !phase.Closed || !phase.BeaconRecorded { + return fmt.Errorf("inspect %s state = %+v, want complete/closed/beaconed", phase.Phase, phase) + } + if phase.AcceptedCount != 2 || phase.ScheduledTotal != 2 { + return fmt.Errorf("inspect %s accepted %d/%d, want 2/2", phase.Phase, phase.AcceptedCount, phase.ScheduledTotal) + } + if phase.NextParticipantID != "" || phase.NextIndex != 0 { + return fmt.Errorf("inspect %s still schedules %q at %d", phase.Phase, phase.NextParticipantID, phase.NextIndex) + } + if len(phase.MissingArtifacts) != 0 { + return fmt.Errorf("inspect %s reports missing artifacts: %v", phase.Phase, phase.MissingArtifacts) + } + if wantSealed := phase.Phase == mpcceremony.Phase1; phase.Sealed != wantSealed { + return fmt.Errorf("inspect %s sealed = %v, want %v", phase.Phase, phase.Sealed, wantSealed) + } + } + } + preliminaryDir := filepath.Join(outputRoot, "preliminary") if _, err := mpcceremony.PrepareFinalization(mpcceremony.PrepareFinalizationOptions{ Replay: replay, From 1f82c96bdcf2a7d93b0102054d505159c6b1ffff Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 08:16:54 +0000 Subject: [PATCH 17/42] Track the change list in the pull request instead of the tree The proposed-changes document was a working audit log; its open items are now tracked in the pull request description, and the fixed items are the pull request's own commits. Rewrite the two runbook references that pointed into it so no dangling links remain. --- docs/mpc-ceremony-local-runbook.md | 6 +- docs/mpc-ceremony-proposed-changes.md | 432 -------------------------- 2 files changed, 3 insertions(+), 435 deletions(-) delete mode 100644 docs/mpc-ceremony-proposed-changes.md diff --git a/docs/mpc-ceremony-local-runbook.md b/docs/mpc-ceremony-local-runbook.md index 8c49d31..cee39c2 100644 --- a/docs/mpc-ceremony-local-runbook.md +++ b/docs/mpc-ceremony-local-runbook.md @@ -10,7 +10,7 @@ ceremony on one machine, and read what comes out. It is **not** a production procedure. The production procedure is `docs/mpc-ceremony-runbook.md` (1,590 lines), which -is currently absent from `main` — see `mpc-ceremony-proposed-changes.md` item B1. +is currently absent from `main`; it was removed by a history-filtering rewrite. It survives in `refs/pull/34/head` of `Anastasia-Labs/proof-tool` at commit `fd8516e`. Anything about enrollment, custody, witnessing, mirrors, beacon selection, or release gates comes from that document, not this one. @@ -204,8 +204,8 @@ came from an independent channel. The next step is `phase1 contribute` for the first scheduled participant, which replays the entire accepted chain before sampling entropy. At K=21 with three -participants that is gigabytes of I/O and hours of verification, with **no -progress output** — see `mpc-ceremony-proposed-changes.md` item A3. +participants that is gigabytes of I/O and hours of verification. Replay +progress is reported on stderr so running can be told apart from hung. For a staged, resumable local run through the whole lifecycle, use the real harness instead of driving the CLI by hand: diff --git a/docs/mpc-ceremony-proposed-changes.md b/docs/mpc-ceremony-proposed-changes.md deleted file mode 100644 index bde2748..0000000 --- a/docs/mpc-ceremony-proposed-changes.md +++ /dev/null @@ -1,432 +0,0 @@ -# MPC Ceremony — Proposed Changes - -Checked against the working tree at `ba065e6` on 2026-08-10. Items marked -**verified** cite the file and line that establishes them. Items marked -**proposal** are new work, not defects. Items marked **open** were not -investigated and are listed so they are not mistaken for cleared. - -No cryptographic break was found. Severity below reflects operational impact. - -## A · Consistency defects - -Both are fail-closed — they block valid work rather than admit invalid work — -but both surface at the worst possible moment. - -### A1 · Audit count is inconsistent across three layers — medium, verified - -A ceremony may enroll **two or more** auditors (`internal/mpcceremony/definition.go:164`). -`SignRelease` accepts **two or more** signed audit reports -(`internal/mpcceremony/audit.go:867`, `len(inputs) < 2`). But `ProductionDecision` -requires **exactly two** (`internal/mpcceremony/decision.go:487`, `len(d.Audits) != 2`). - -A ceremony that enrolls three auditors — permitted, and strictly more -conservative — can therefore produce a valid signed release that can never be -recorded in a valid production decision. The failure appears after the ceremony -is complete, at final GO signing, when nothing can be redone. - -**Fix.** Pick one rule and apply it in all three places. Accepting `>= 2` in the -decision is the better direction: more independent auditors should never be -harder to record than the minimum. The same question applies to `ExternalAudits` -at `internal/mpcceremony/decision.go:501`. - -### A2 · The runbook's failover drill calls a command that does not exist — medium, verified - -Step 3 of the Restore And Failover Drill instructs the operator to "run read-only -`inspect`, and compare the derived next participant/index with the primary run -card." There is no `inspect` in the CLI — neither `cmd/mpc-ceremony/parse.go` nor -`cmd/mpc-ceremony/usage.go` mentions it. - -The only `inspect` is a stage of `scripts/run-mpc-k21-local-rehearsal.sh:1616`, -and it reads that script's own `state/steps/*.complete` markers rather than the -signed chain. A production ceremony driven through the CLI directly — which is -what the runbook's main body documents — has no recovery inspection at all. - -The answer is a pure function of already-signed data: - - next_index = len(chain.Records) + 1 - next_participant = policy.Participants[len(chain.Records)] - -with the frozen order enforced at `internal/mpcceremony/chain.go:283-286`. No -signing key and no replay are required. - -**Fix.** Add `mpc-ceremony inspect` — read-only, public keys only, never writes. -Report ceremony ID and mode, per-phase accepted count and head record ID, next -scheduled participant and index, and which artifacts are present or missing. Two -verification depths: metadata-and-hashes by default (seconds), full replay behind -`--full` (hours at K=21). It must state which depth it ran; during a recovery -window nobody waits for the replay. - -### A3 · Long-running commands report no progress — medium, verified - -`internal/mpcceremony` has no logger and no print path at all. That is the right -call for this domain: the package handles signing keys and secret contribution -state, and having no output path is stronger than having a careful one. It also -keeps operations deterministic and replayable with no side channels. The CLI -reinforces it by redirecting gnark's global logger to stderr so stdout carries -only the result contract (`cmd/mpc-ceremony/main.go:20-23`). - -The cost is that a K=21 phase close replays for hours with zero output. An -operator cannot distinguish running from hung, and cannot calibrate how long a -close actually takes on their hardware. - -That is not merely a usability complaint. Misjudging replay duration is precisely -what caused the 2026-07-24 closure-timing incident: the operator chose a beacon -round roughly an hour out, the replay took longer than that, and the round was -already public by the time the closure was written. The current code fails -loudly in that situation (see the `validateCloseCommitTime` guard), so the unsafe -closure can no longer be produced — but the operator still burns the attempt and -must restart with a farther round, having no better information than last time -about how far is far enough. - -**Fix.** Add progress reporting that does not weaken the boundary. Two options -that both preserve the no-print rule inside the package: - -- an optional progress callback on the `*Options` structs, invoked per replayed - contribution with an index and count, which the CLI renders to **stderr**; or -- structured timing returned in the `*Result` struct, so the CLI can report - measured per-contribution and total replay duration after the fact. - -The callback form is more useful operationally because it also feeds the -beacon-round choice: an operator who can see "contribution 3 of 5, 41 minutes -elapsed" can pick a safe round. Neither form prints from the package, and neither -carries secret material — an index, a count, and a duration only. - -**Coverage gap, found 2026-08-16 and fixed.** The callback landed on -`PhaseTranscriptPaths`, so it reached every command that builds its paths -through the CLI's `transcriptPaths` helper — contribute, verify, close. It did -not reach `phase1 seal`, whose options carry a bare `TranscriptRoot` string and -which constructs its own `PhaseTranscriptPaths` internally (`workflow.go:1881`) -with no `Progress` field to populate. - -The seal replays the entire phase and then applies the beacon contribution, so -it does strictly more work than a close. Observed on a production-mode K=21 run: -the close reported three progress lines and completed in 1h40m33s, while the -seal ran silently past 2h25m. The one operation an operator is most likely to -think has hung was the only long one saying nothing. - -`SealPhase1FilesOptions` now carries `Progress` and threads it into the paths it -builds; the CLI attaches the same reporter it uses elsewhere. The workflow -integration helper asserts the callback fires during a seal, so the wiring -cannot be silently dropped again. - -**Second gap: the callback shape does not fit every long command.** With the -seal covered, phase 2 initialization was still silent past 2h20m on the same -run. It is not a plumbing omission — `InitializePhase2Files` performs no replay, -so a per-contribution callback has nothing to count. It loads and verifies the -sealed phase 1 commons, transforms them into circuit-specific parameters across -the whole 2^21 domain, and publishes the result; the transform is one monolithic -computation inside gnark that exposes no progress of its own. - -`ReplayProgress` therefore cannot describe it, and reporting a fabricated -percentage would be worse than silence. Added `StageProgress` -(`func(stage string, index, total int)`) and three reported stages, so an -operator sees which stage is running and how long it has been running. Coarser -than a replay index, and honest about it: the value is separating running from -hung and naming what is being waited on. The CLI renders it to stderr like the -replay reporter, and the integration helper asserts all three stages arrive in -order. - -`RecordBeaconFiles` also takes a bare transcript root but is short and performs -no replay, so it needs nothing. - -### A4 · CLI error redaction is a per-call-site blocklist — low, verified - -Before printing an error, the CLI runs the message through `redactCLIError` -(`cmd/mpc-ceremony/main.go:137-167`), which collects argv-derived strings, sorts -them longest-first, and `strings.ReplaceAll`s them out. The intent is right: -arguments include signing-key paths. Three limits are worth recording. - -1. **It only catches what literally appears in argv.** A path read from a config - file, or any value derived from a key, is not in the candidate set and passes - through unmodified. -2. **It is opt-in per call site.** `writeDiagnostic` (`main.go:227`) performs no - redaction; only the error paths call `redactCLIError`. A new diagnostic that - forgets it leaks silently, and nothing in the build catches that. -3. **The candidate guard is minimal.** `addCLIErrorCandidate` rejects only `""`, - `"-"` and `"--"` (`main.go:220-225`), so a short argument value can blank - unrelated substrings of a message. That is over-redaction rather than a leak, - but it degrades diagnostics exactly when they are needed. - -This is defense-in-depth, not the actual control. The real protection is that -`internal/mpcceremony` has no print path at all, so secret material is never in a -position to be written. Redaction is the net under that. - -**Fix.** Low priority, but two cheap hardening steps: route *all* CLI output -through one helper that redacts by construction, so a new call site cannot opt -out by accident; and add a minimum-length floor in `addCLIErrorCandidate` to stop -short values blanking unrelated text. Neither changes the trust boundary. - -### A5 · The beacon round is chosen before the replay that decides whether it is still valid — medium, verified - -`phase1 close` and `phase2 close` take `--beacon-round N` up front, then replay -the whole accepted phase, then sample `closed_at` and check the round is still -in the future with the signed lead intact. At K=21 that replay takes hours, so -the operator is really being asked to predict their own hardware: name a round -too near and the entire replay is discarded. - -This is the same failure as the 2026-07-24 incident. A3 added replay progress -reporting, which tells an operator how long the replay took once they have -already run one — so it informs the round they pick when retrying a close that -was just rejected, and does nothing for the first close on a given host, which -is the one that must be guessed blind. It -was hit again on 2026-08-16 during a full production-mode run, on a machine -whose replay had never been measured, by picking the round from the signed lead -plus a margin — which is the only rule written down anywhere. Measured cost of -the discarded attempt: 1h40m of replay, from this progress output: - - replaying phase1 contribution 1/3 (48m34s elapsed) - replaying phase1 contribution 2/3 (1h14m34s elapsed) - replaying phase1 contribution 3/3 (1h40m24s elapsed) - -The signed `minimum_witness_lead_seconds` states how much time *witnesses* need. -It says nothing about how long *this host* takes to replay. Those are unrelated -quantities and only the first is recorded in the ceremony. - -Nothing requires the round to be chosen early. It is not published, signed, or -observable until the closure record is written at the end, so choosing it after -the replay is indistinguishable to every observer and cannot help a coordinator: -the round is still in the future at publication, and its randomness does not -exist under either ordering. - -**Fix — implemented 2026-08-16.** `--beacon-round-lead SECONDS` on both close -commands, mutually exclusive with `--beacon-round`. - -The derivation has to happen inside the package, not the CLI. Only the package -knows when the replay finished, and `closedAt` is sampled in -`publishReplayedPhaseClose` after it; a CLI deriving beforehand would be making -the same blind guess. `FirstQuicknetRoundAfter` (`chain.go`) inverts -`QuicknetRoundTime`, and the round is derived from `closedAt` plus the larger of -the requested lead and the signed minimum, plus the publication safety margin -that `validateCloseCommitTime` re-checks against a second clock sample. - -Two existing checks assumed an explicit round and were narrowed rather than -removed. Retry recovery compares a published closure's round against the -requested one; with derivation there is no operator intent to contradict, so the -comparison now applies only when a round was named, and the existing record is -authenticated and fully revalidated either way. The phase 2 round-reuse check -runs before the replay, so a derived round is checked for reuse after -derivation instead. - -`--beacon-round` is unchanged, for staged runs where the round is announced out -of band. - -## B · Documentation integrity - -### B1 · Eight governance documents were stripped from `main`; ten links to them remain — high, verified - -PR #34 merged, but the branch was history-filtered and force-pushed first. -Diffing the pull-request head against the merged head yields exactly eight -deleted documentation files, 2,738 lines, and **zero code changes**. Both -lineages have 217 commits with byte-identical author and committer timestamps — -the signature of a path-filtering rewrite, not a revert. - - docs/mpc-ceremony-runbook.md 1590 - docs/mpc-external-audit-package.md 202 - docs/mpc-production-readiness.md 198 - docs/mpc-security-review.md 192 - docs/mpc-production-go-no-go-template.md 187 - docs/production-readiness.md 143 - docs/next-steps-to-mainnet.md 124 - docs/mainnet-deployment-preparation.md 102 - -No commit deletes them; they survive only in `refs/pull/34/head` (`fd8516e`) of -`https://github.com/Anastasia-Labs/proof-tool`. Meanwhile `docs/README.md` still -indexes five of them with full descriptions — including "the formal mainnet -go/no-go matrix, current **NO-GO**, blocking rehearsal incident" — and -`docs/trusted-setup-ceremony.md` links three more. Ten dangling references in -total. - -The practical effect: `main` advertises a NO-GO decision record it does not -contain, and the procedure governing a mainnet trusted setup exists only inside a -pull-request ref. - -**Fix.** Ask upstream whether the removal was deliberate before restoring -anything — documents that say NO-GO and disqualify the current binary may have -been withheld on purpose. If deliberate, remove the ten dangling links so the -index stops advertising absent files. If accidental, restore all eight. The -current state is the worst of both. - -## C · New capability: object-storage backend (S3/R2) - -Proposal, not a defect. The governing rule is one sentence: **object storage is -transport, never trust.** - -### C1 · Keep all fetching outside the ceremony binary - -`internal/mpcceremony` imports no networking at all, deliberately. The runbook's -guarantee boundary lists "no implicit `latest`, overwrite, or network-fetch -behavior" as an enforced property, and `internal/mpcceremony/decision.go:84` -states that verification "never fetches a URI or trusts mutable network state." -Putting fetch inside the binary deletes a stated security property. - -**Design.** A separate sync tool moves bytes; the ceremony tool keeps verifying -local files. Downloading is already safe because every artifact is pinned by -digest in the signed chain and re-checked by `verifyArtifactBytes` — a hostile -bucket can cause a failure, never a forgery. - -### C2 · Closure publication has no atomic equivalent in object storage — highest risk of this section - -On-disk safety rests on `RENAME_NOREPLACE` and staged directories published by -atomic rename. S3/R2 has no atomic directory rename. Per-object create-if-absent -is available via conditional writes (`If-None-Match: *`), but a closure directory -can become **half-visible** — and the closure is precisely the artifact whose -publication moment is security-critical, since the 2026-07-24 incident was a -closure-timing failure. - -**Design.** Upload closure objects under a temporary prefix, then make them -visible by writing a single immutable pointer object last. One object flip, not a -multi-object window. - -### C3 · A mirror is only immutable if the bucket enforces it - -Anyone holding credentials can overwrite an object. To honestly claim an -`ImmutableMirrorReceipt`, the bucket needs object lock, retention, and -versioning — and the receipt should record that configuration alongside -`StorageLocationSHA256`. - -Independence is a separate requirement: two buckets in one R2 account is one -mirror. The gate wants distinct operators, exactly as the three-relay beacon rule -does. - -### C4 · Reuse the existing publication allowlist; emit real mirror receipts - -`scripts/package-mpc-public-evidence.sh` already builds a "fail-closed, -content-hashed public evidence tree" where "private control keys and files -outside the explicit allowlist are never copied." Do not write a second answer to -*what may be published* — that is how a signing key reaches a bucket. - -On the other side, the sync tool should emit `ImmutableMirrorReceipt` records -(`internal/mpcceremony/operational.go:341`) from its uploads. Those feed the -operational evidence bundle and satisfy the two-independent-mirrors gate, so the -work lands in a slot the schema already has. - -- Verify by re-downloading and re-hashing, not by trusting the upload response. -- A `latest` pointer is for humans; no tool may resolve one. -- Sizing is comfortable: roughly 3.6 GB of accepted state at five participants - and roughly 9 GB of cumulative prefix downloads. R2 zero-egress matters because - each participant pulls the full prefix before contributing. - -## D · Open — not yet investigated - -### D1 · The verifying-key seam between ceremony and deployed validator - -The ceremony's entire output is a verifying key that -`contracts/ownership-verifier` consumes — 785 lines in `src/Ownership/Verify.hs` -doing on-chain BLS12-381 Groth16, parsing the VK from a `BuiltinByteString`. A -flawless ceremony plus a validator that misparses or misapplies that VK still -loses funds; a perfect validator fed a compromised VK verifies forgeries happily. -Neither audit covers the seam. - -Start from `scripts/verify-mpc-final-plutus-evidence.sh` and -`internal/mpcceremony/plutus_evidence_script_test.go` — they exist specifically to -test this seam, so they record what the authors already believed needed proving. - -**Partially traced, and there is a gap.** The VK reaches the chain as a -compile-time script parameter. `reclaim-scripts-export global-v2` takes -`<672-byte-cardano-verifier-key-hex>` *and* -`` as two separate arguments -(`contracts/ownership-verifier/export/ReclaimDeploymentScripts.hs:79`). -`printGlobalV2Script` then prints that hash straight into the exported JSON's -`verifier_vk_hash` field without ever hashing the VK bytes it compiled in -(`ReclaimDeploymentScripts.hs:91-95`). The exporter will therefore emit a script -that verifies against VK *A* while its manifest advertises `blake2b256(B)`. - -Whether a downstream check binds them — `verify-proof-release.mjs`, the -reclaim-server manifest code, or the coherence checks the runbook lists — is not -yet traced. The current Preprod manifest -(`apps/ownership-proof-web/public/proof-assets/reclaim-deployment.json`) is -self-consistent, with `reclaim_global.verifier_vk_hash` equal to -`proof.cardano_vk_blake2b256`, and is honestly labelled -`destination_key_provenance: "single-actor local Preprod setup; not an MPC -ceremony"`. - -This is the same failure shape as the snarkjs/Circom incidents documented by -zkSecurity (Foom, ~$1.4M; Veil, 2.9 ETH): correct library, correct maths, wrong -artifact deployed. - -### D2 · Subgroup checks are disabled on the streaming proving-key path - -BLS12-381's curves have cofactors, so points on `E(F_p)` outside the order-`r` -subgroup exist. Accepting one as a group element is the classic small-subgroup / -invalid-curve failure (Cremers and Jackson, *Prime, Order Please!*, CSF 2019). - -The ceremony path is closed. gnark-crypto's `NewDecoder` defaults -`subGroupCheck: true` (`ecc/bls12-381/marshal.go:63`), mpcsetup's `ReadFrom` uses -that default, and `UpdateProof.Verify` additionally runs explicit -`IsInSubGroup()` on both proof points and rejects the infinity point -(`ecc/bls12-381/mpcsetup/mpcsetup.go:94-99`). - -Six call sites outside the ceremony explicitly opt out: - - internal/streampk/keysource.go:116,133,378,393 - internal/msmengine/serialize.go:112,148 - -All pass `curve.NoSubgroupChecks()`. Both callers were traced. They differ. - -**`msmengine` is authenticated — no issue found.** The chunked browser path -verifies every chunk before any decoder sees it -(`apps/ownership-proof-web/public/proof-runtime/msm-worker.js:317-326`): exact -size, `content-encoding: identity` enforced, then `__msmengineVerifyChunkBytes` -against both the `sha256` and `blake2b256` recorded in the signed -`ChunkManifest`, with verify-before-cache so rejected bytes cannot enter the LRU. -The unchecked decoder is reached only via `unmarshalG1PointsPinned` / -`unmarshalG2PointsPinned`, whose doc comment states they decode -"digest-authenticated proving-key points", and which still run `IsOnCurve()` on -every point after skipping the subgroup check. The checked sibling -`unmarshalG1Points` uses `SetBytes`, which validates subgroups. - -One fragility worth fixing anyway: `pinnedDecode` defaults to `true` -(`cmd/wasm-prover/main_js.go:934`) and is overridable from request JSON -(`req.PinnedDecode`). It is a tuning knob, not a value derived from whether the -bytes were actually verified. Safe today only because the fetch path always -verifies; nothing enforces the coupling. - -**`streampk` is NOT authenticated on the URL path — this is the real finding.** -`internal/streampk` contains no digest verification at all: grepping -`range.go`, `keysource.go` and `index.go` for sha256/blake2b/digest/verify -returns nothing. `ValidateIndex` validates structure, not content. - -Its two callers diverge: - -- `openStreamingArtifactsFromDir` (`cmd/wasm-prover/main_js.go:1332-1357`) - verifies the proving key's SHA-256, BLAKE2b-256 **and** size against the signed - key manifest before calling `streampk.OpenKeyFile`. Correct. -- `openStreamingArtifactsFromURLs` (`main_js.go:1360-1441`) verifies the - verifying key thoroughly (hash, sha256, size) and compares the index's - `file_size` to the manifest — but **never digests the proving key bytes**. It - then calls `streampk.OpenKeyURL(&index, pkURL, opts...)`, which issues HTTP - range requests straight into decoders that skip subgroup checks. The key - manifest signature is itself optional on this path - (`verifyOptionalKeyManifestSignature`). - -The exposure is immediate rather than theoretical: `KeySource.open` -(`internal/streampk/keysource.go:112-139`) range-reads the G1 singletons -(alpha, beta, delta) and the G2 singletons (beta, delta) and decodes all five -with `NoSubgroupChecks()` at open time — before any chunk-manifest machinery -applies, and with no on-curve check either, unlike the `msmengine` pinned path. - -This is exactly the primitive the ZKHack trusted-setup puzzle exploits: a point -that parses, lies on the curve, and sits outside the order-r subgroup leaks the -secret scalar to Pohlig-Hellman over the smooth cofactor. BLS12-381's G1 -cofactor `(x-1)^2/3` factors into 3, 11, 10177, 859267 and 52437899, so the -smooth part is trivially attackable. What is at risk here is a proving key rather -than a ceremony secret, so the impact is malformed-input handling and possible -incorrect proofs rather than direct key recovery — but the missing check is the -same one. - -**Fix.** Either verify the proving key digest on the URL path before opening the -source, or have `streampk` verify per-range digests from a pinned index the way -the chunk path does. At minimum, add `IsOnCurve()` after the singleton decode so -`streampk` is no weaker than `msmengine`, and make `pinnedDecode` derive from -verification state rather than being caller-supplied. - -**Still open.** Whether `openStreamingArtifactsFromURLs` is reachable in a -production deployment, or whether shipping configurations always route through -the chunk-manifest path. That determines severity, not whether the gap exists. - -### D3 · Sweep the remaining twelve gates for the A1 defect class - -A1 was found by comparing what the definition permits, what the release accepts, -and what the decision demands for one gate. The other twelve were not checked for -the same mismatch — witnesses, mirrors, relay operators, and participant counts -all have counts asserted in more than one layer. From b7b9aa6b8b8ff2ba6cff0033c21fe60e97170e43 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 08:43:42 +0000 Subject: [PATCH 18/42] Reserve a witness observation window in production closes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The signed minimum witness lead is measured from two different anchors: ValidateClose measures roundTime-closedAt and accepted equality, while witness receipts measure roundTime-observedAt with observedAt strictly after closedAt. A production close at exactly the signed minimum — the value the close help text sanctions — therefore left public witnesses a window of seconds (or zero, with an explicit round) in which a valid receipt could exist, and the contradiction surfaced only when the operational evidence bundle was assembled at release, with the round already pinned inside the signed closure and the phase unrecoverable. Production closes must now reserve ProductionWitnessObservationWindowSeconds (one hour) on top of the signed minimum, enforced consistently in ValidateClose, in the derived-round computation, and in the pre-publication commit-time guard via a single requiredCloseLead helper. Rehearsals are exempt: their leads are minutes and their witness receipts are same-host fixtures. --- .../beacon_round_derivation_test.go | 14 +++-- internal/mpcceremony/chain.go | 23 +++++++++ internal/mpcceremony/chain_test.go | 29 +++++++++-- internal/mpcceremony/close_timing_test.go | 51 +++++++++++++++++-- internal/mpcceremony/definition.go | 18 +++++++ internal/mpcceremony/workflow.go | 17 ++++--- 6 files changed, 131 insertions(+), 21 deletions(-) diff --git a/internal/mpcceremony/beacon_round_derivation_test.go b/internal/mpcceremony/beacon_round_derivation_test.go index d65cb62..07741b5 100644 --- a/internal/mpcceremony/beacon_round_derivation_test.go +++ b/internal/mpcceremony/beacon_round_derivation_test.go @@ -60,6 +60,10 @@ func TestFirstQuicknetRoundAfterBeforeGenesis(t *testing.T) { // that rejects a round an operator named before a multi-hour replay. func TestDerivedRoundClearsTheSignedLead(t *testing.T) { const leadSeconds = 600 + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: leadSeconds}, + } closedAt := time.Unix(BeaconQuicknetGenesis+1_000_000, 0).UTC() lead := leadSeconds * time.Second @@ -71,7 +75,7 @@ func TestDerivedRoundClearsTheSignedLead(t *testing.T) { if err != nil { t.Fatal(err) } - if err := validateCloseCommitTime(closedAt, closedAt, roundTime, leadSeconds); err != nil { + if err := validateCloseCommitTime(closedAt, closedAt, roundTime, definition); err != nil { t.Fatalf("derived round rejected by the publication guard: %v", err) } if roundTime.Sub(closedAt) < lead { @@ -84,6 +88,10 @@ func TestDerivedRoundClearsTheSignedLead(t *testing.T) { // the past when the closure is published. func TestExplicitRoundStaleAfterLongReplayIsRejected(t *testing.T) { const leadSeconds = 600 + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: leadSeconds}, + } chosenAt := time.Unix(BeaconQuicknetGenesis+1_000_000, 0).UTC() // The operator picks a round just past the signed lead, as the only written @@ -99,7 +107,7 @@ func TestExplicitRoundStaleAfterLongReplayIsRejected(t *testing.T) { // The replay then takes an hour and forty minutes. closedAt := chosenAt.Add(100 * time.Minute) - if err := validateCloseCommitTime(closedAt, closedAt, roundTime, leadSeconds); err == nil { + if err := validateCloseCommitTime(closedAt, closedAt, roundTime, definition); err == nil { t.Fatal("stale round was accepted after a long replay") } @@ -114,7 +122,7 @@ func TestExplicitRoundStaleAfterLongReplayIsRejected(t *testing.T) { if err != nil { t.Fatal(err) } - if err := validateCloseCommitTime(closedAt, closedAt, derivedTime, leadSeconds); err != nil { + if err := validateCloseCommitTime(closedAt, closedAt, derivedTime, definition); err != nil { t.Fatalf("derived round rejected: %v", err) } } diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index a543831..e8f172b 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -588,9 +588,32 @@ func ValidateClose(definition CeremonyDefinition, chain Chain, close CloseRecord minimumLead, ) } + if requiredLead := requiredCloseLead(definition); roundTime.Sub(closedAt) < requiredLead { + return fmt.Errorf( + "beacon round lead %s does not reserve the production witness observation window: need %s (signed minimum %s plus %s window)", + roundTime.Sub(closedAt), + requiredLead, + minimumLead, + requiredLead-minimumLead, + ) + } return nil } +// requiredCloseLead is the beacon lead a close must reserve, measured from +// closed_at: the signed minimum witness lead, plus — in production — the +// witness observation window. Witness receipts measure the same signed +// minimum from their own observation time, which is strictly after closed_at, +// so without the reserved window a close at the bare minimum makes every +// witness receipt unsatisfiable. See ProductionWitnessObservationWindowSeconds. +func requiredCloseLead(definition CeremonyDefinition) time.Duration { + lead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second + if definition.Mode == ModeProduction { + lead += time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second + } + return lead +} + type BeaconRecord struct { Schema string `json:"schema"` BeaconID string `json:"beacon_id"` diff --git a/internal/mpcceremony/chain_test.go b/internal/mpcceremony/chain_test.go index ddc0065..48e5f4e 100644 --- a/internal/mpcceremony/chain_test.go +++ b/internal/mpcceremony/chain_test.go @@ -188,9 +188,13 @@ func TestValidateBeaconRecomputesAgainstBoundClose(t *testing.T) { BeaconNetwork: definition.BeaconPolicy.Network, BeaconRound: 30699432, BeaconNotBefore: roundTime.Format(time.RFC3339), - ClosedAt: roundTime.Add(-minimumLead - time.Minute).Format(time.RFC3339), - CoordinatorID: definition.Coordinator.ID, - CoordinatorKeyID: definition.Coordinator.KeyID, + ClosedAt: roundTime.Add( + -minimumLead - + time.Duration(ProductionWitnessObservationWindowSeconds)*time.Second - + time.Minute, + ).Format(time.RFC3339), + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, }) if err != nil { t.Fatal(err) @@ -198,14 +202,29 @@ func TestValidateBeaconRecomputesAgainstBoundClose(t *testing.T) { if err := ValidateClose(definition, chain, closeRecord); err != nil { t.Fatal(err) } + // Production must reserve the witness observation window on top of the + // signed minimum: witness receipts measure the same minimum from their + // observation time, which is strictly after closed_at, so a close at the + // bare minimum would make every witness receipt unsatisfiable. + window := time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second exactLead := closeRecord exactLead.ClosedAt = roundTime.Add(-minimumLead).Format(time.RFC3339) exactLead, err = NewCloseRecord(exactLead) if err != nil { t.Fatal(err) } - if err := ValidateClose(definition, chain, exactLead); err != nil { - t.Fatalf("close at exact signed minimum witness lead rejected: %v", err) + if err := ValidateClose(definition, chain, exactLead); err == nil || + !strings.Contains(err.Error(), "witness observation window") { + t.Fatalf("production close at bare signed minimum error = %v, want witness-window rejection", err) + } + windowedLead := closeRecord + windowedLead.ClosedAt = roundTime.Add(-minimumLead - window).Format(time.RFC3339) + windowedLead, err = NewCloseRecord(windowedLead) + if err != nil { + t.Fatal(err) + } + if err := ValidateClose(definition, chain, windowedLead); err != nil { + t.Fatalf("production close reserving the witness window rejected: %v", err) } belowLead := exactLead belowLead.ClosedAt = "2026-07-23T14:01:00.000000001Z" diff --git a/internal/mpcceremony/close_timing_test.go b/internal/mpcceremony/close_timing_test.go index 3876675..d574fde 100644 --- a/internal/mpcceremony/close_timing_test.go +++ b/internal/mpcceremony/close_timing_test.go @@ -11,6 +11,10 @@ func TestValidateCloseCommitTimeBoundaries(t *testing.T) { roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) const minimumLead uint32 = 300 + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: minimumLead}, + } requiredLead := time.Duration(minimumLead)*time.Second + closePublicationSafetyMargin closedAt := roundTime.Add(-requiredLead - time.Second) @@ -18,7 +22,7 @@ func TestValidateCloseCommitTimeBoundaries(t *testing.T) { closedAt, roundTime.Add(-requiredLead), roundTime, - minimumLead, + definition, ); err != nil { t.Fatalf("exact publication boundary rejected: %v", err) } @@ -26,7 +30,7 @@ func TestValidateCloseCommitTimeBoundaries(t *testing.T) { closedAt, roundTime.Add(-requiredLead+time.Nanosecond), roundTime, - minimumLead, + definition, ); err == nil || !strings.Contains(err.Error(), "below required") { t.Fatalf("publication below boundary error = %v, want lead rejection", err) } @@ -37,11 +41,15 @@ func TestValidateCloseCommitTimeRejectsClockRollbackAndZeroTimes(t *testing.T) { roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) closedAt := roundTime.Add(-time.Hour) + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: 300}, + } if err := validateCloseCommitTime( closedAt, closedAt.Add(-time.Nanosecond), roundTime, - 300, + definition, ); err == nil || !strings.Contains(err.Error(), "moved backwards") { t.Fatalf("clock rollback error = %v, want rollback rejection", err) } @@ -49,7 +57,7 @@ func TestValidateCloseCommitTimeRejectsClockRollbackAndZeroTimes(t *testing.T) { time.Time{}, closedAt, roundTime, - 300, + definition, ); err == nil || !strings.Contains(err.Error(), "zero time") { t.Fatalf("zero closed_at error = %v, want zero-time rejection", err) } @@ -57,8 +65,41 @@ func TestValidateCloseCommitTimeRejectsClockRollbackAndZeroTimes(t *testing.T) { closedAt, time.Time{}, roundTime, - 300, + definition, ); err == nil || !strings.Contains(err.Error(), "zero time") { t.Fatalf("zero commit time error = %v, want zero-time rejection", err) } } + +func TestValidateCloseCommitTimeReservesProductionWitnessWindow(t *testing.T) { + t.Parallel() + + roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) + definition := CeremonyDefinition{ + Mode: ModeProduction, + BeaconPolicy: BeaconPolicy{ + MinimumWitnessLeadSeconds: ProductionMinimumWitnessLeadSeconds, + }, + } + requiredLead := time.Duration( + ProductionMinimumWitnessLeadSeconds+ProductionWitnessObservationWindowSeconds, + )*time.Second + closePublicationSafetyMargin + closedAt := roundTime.Add(-requiredLead - time.Second) + + if err := validateCloseCommitTime( + closedAt, + roundTime.Add(-requiredLead), + roundTime, + definition, + ); err != nil { + t.Fatalf("production close reserving the witness window rejected: %v", err) + } + if err := validateCloseCommitTime( + closedAt, + roundTime.Add(-requiredLead+time.Second), + roundTime, + definition, + ); err == nil || !strings.Contains(err.Error(), "below required") { + t.Fatalf("production close without the witness window error = %v, want lead rejection", err) + } +} diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index cd39d22..194ea5e 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -7,6 +7,21 @@ import ( const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 +// ProductionWitnessObservationWindowSeconds is the observation time a +// production close must reserve for public witnesses on top of the signed +// minimum witness lead. +// +// The signed minimum is measured from two different anchors: ValidateClose +// measures roundTime-closedAt, while witness receipts measure +// roundTime-observedAt with observedAt strictly after closedAt. A close at +// exactly the signed minimum therefore leaves witnesses no time in which a +// valid receipt can exist, and the mismatch surfaces only when the evidence +// bundle is assembled at release, when the round is already pinned inside the +// signed closure. Reserving an explicit window at close keeps the witness +// requirement satisfiable. Rehearsals are exempt: their leads are minutes and +// their witness receipts are same-host fixtures. +const ProductionWitnessObservationWindowSeconds uint32 = 60 * 60 + type CeremonyDefinition struct { Schema string `json:"schema"` CeremonyID string `json:"ceremony_id"` @@ -164,6 +179,9 @@ func (d CeremonyDefinition) validate(requireID bool) error { if len(d.Auditors) < 2 { return errors.New("at least two independent auditors are required") } + if len(d.Auditors) > MaxAuditors { + return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) + } identityIDs := map[string]string{ d.Coordinator.ID: "coordinator", d.ReleaseSigner.ID: "release signer", diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 2be0d01..bccf060 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -75,6 +75,9 @@ func (p InitParticipants) Validate() error { if len(p.Auditors) < 2 { return errors.New("at least two independent auditors are required") } + if len(p.Auditors) > MaxAuditors { + return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) + } if len(p.Roster) == 0 || len(p.Roster) > MaxParticipants { return fmt.Errorf("roster must contain between 1 and %d participants", MaxParticipants) } @@ -1587,10 +1590,8 @@ func publishReplayedPhaseClose( // sample and demands the signed minimum plus a safety margin, so derive // past that rather than past the bare minimum. lead := time.Duration(options.BeaconRoundLeadSeconds) * time.Second - if minimum := time.Duration( - trusted.Definition.BeaconPolicy.MinimumWitnessLeadSeconds, - ) * time.Second; lead < minimum { - lead = minimum + if required := requiredCloseLead(trusted.Definition); lead < required { + lead = required } beaconRound, err = FirstQuicknetRoundAfter( closedAt.Add(lead + closePublicationSafetyMargin), @@ -1659,7 +1660,7 @@ func publishReplayedPhaseClose( closedAt, now().UTC(), roundTime, - trusted.Definition.BeaconPolicy.MinimumWitnessLeadSeconds, + trusted.Definition, ) }, ); err != nil { @@ -1673,7 +1674,7 @@ func validateCloseCommitTime( closedAt time.Time, commitTime time.Time, roundTime time.Time, - minimumWitnessLeadSeconds uint32, + definition CeremonyDefinition, ) error { if closedAt.IsZero() { return errors.New("closure clock returned the zero time") @@ -1684,11 +1685,11 @@ func validateCloseCommitTime( if commitTime.Before(closedAt) { return errors.New("closure clock moved backwards before publication") } - minimumLead := time.Duration(minimumWitnessLeadSeconds) * time.Second + minimumLead := requiredCloseLead(definition) requiredLead := minimumLead + closePublicationSafetyMargin if roundTime.Sub(commitTime) < requiredLead { return fmt.Errorf( - "beacon round lead at closure publication %s is below required %s (signed witness lead %s plus publication margin %s)", + "beacon round lead at closure publication %s is below required %s (required witness lead %s plus publication margin %s)", roundTime.Sub(commitTime), requiredLead, minimumLead, From 5a5a21d35c9e06d2530673d900813f44d2d49063 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 08:43:42 +0000 Subject: [PATCH 19/42] Align counted gates across layers so diligence cannot strand a release Three more instances of the audit-count defect class: a limit asserted in one layer that another layer exceeds, fail-closed but discovered only at release or decision time, after the work is complete. - Auditors: the final transcript stores audits in a list capped at 20, but enrollment, release, and decision accepted any count >= 2. A ceremony with 21 auditors completed every audit and then could not bundle them, and the dropped auditor was barred from the GO decision. Enrollment, definition validation, and the CLI now enforce 2..MaxAuditors, matching the transcript. - Audit order: release bundled audit reports in --audit-report flag order and froze that order into the transcript ID, while the decision requires its audits ascending by auditor ID and the transcript refs to match that order exactly. Reports passed in any other order signed a release for which no valid decision could exist. bundleAuditArtifacts now sorts by the auditor ID each record names before bundling. - Release tree ceiling: the decision capped the pinned artifact list at 4096 files while the bundle layers permit roughly four times that from governance evidence alone, so a thoroughly documented ceremony could sign a release the decision then rejected. The ceiling is now 32768, derived in a comment from the bundle layers' own maxima. Also corrects documentation drift from the earlier >= 2 auditors fix: the decision help no longer says "the two auditors" or shows exactly four signature flags, and the wrong-signer error no longer says "either audit". --- cmd/mpc-ceremony/parse.go | 3 ++ cmd/mpc-ceremony/usage.go | 7 ++-- internal/mpcceremony/audit.go | 40 ++++++++++++++++++++ internal/mpcceremony/decision.go | 11 +++++- internal/mpcceremony/decision_test.go | 2 +- internal/mpcceremony/definition_gate_test.go | 34 +++++++++++++++++ internal/mpcceremony/model.go | 6 +++ 7 files changed, 97 insertions(+), 6 deletions(-) create mode 100644 internal/mpcceremony/definition_gate_test.go diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index f3ebcfd..447de82 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -877,6 +877,9 @@ func validateAuditArtifacts(reports, signatures []string) error { if len(reports) < 2 { return errors.New("--audit-report must be supplied at least twice for independent audits") } + if len(reports) > mpcceremony.MaxAuditors { + return fmt.Errorf("--audit-report supplied %d times, exceeds maximum %d recordable in the final transcript", len(reports), mpcceremony.MaxAuditors) + } if len(reports) != len(signatures) { return errors.New("--audit-report and --audit-signature counts must match") } diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index c6197f1..fd5ec25 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -345,8 +345,9 @@ the accountable roles should sign. --signing-key KEY --out FRESH_FILE Signs the exact canonical decision bytes with one enrolled ceremony identity. -A GO record requires the coordinator, the two auditors named by the record, -and the distinct release signer to sign the same bytes. Before loading a GO +A GO record requires the coordinator, every auditor named by the record, +and the distinct release signer to sign the same bytes — one signature per +named auditor, so a ceremony with three auditors needs five signatures. Before loading a GO signing key, the command hashes and semantically verifies the full local evidence set. Evidence verification is optional for a NO-GO record so an accountable role can sign a fail-closed decision that reports unavailable @@ -355,7 +356,7 @@ evidence. "decision verify": `Usage: mpc-ceremony decision verify --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --decision FILE \ - --signature FILE --signature FILE --signature FILE --signature FILE \ + --signature FILE [--signature FILE ...] \ --evidence-root DIR Strictly parses the record and detached role signatures, hashes every local diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 7d98276..aceb6a8 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -1318,11 +1318,21 @@ func copyOperationalEvidence( return nil } +// bundleAuditArtifacts copies the audit reports into the staging tree in +// ascending auditor-ID order. The bundled order is frozen into the final +// transcript, and the production decision independently requires its audits +// ascending by auditor ID and then requires the transcript refs to match that +// order exactly — so bundling in the caller's flag order would sign a release +// for which no valid decision can ever exist. func bundleAuditArtifacts(inputs []AuditArtifact, stagingDir string) ([]AuditArtifact, error) { auditDir := filepath.Join(stagingDir, "audits") if err := os.Mkdir(auditDir, 0o700); err != nil { return nil, err } + inputs, err := sortAuditArtifactsByAuditorID(inputs) + if err != nil { + return nil, err + } result := make([]AuditArtifact, len(inputs)) for index, input := range inputs { logicalRecord := fmt.Sprintf("audits/%04d.json", index+1) @@ -1344,6 +1354,36 @@ func bundleAuditArtifacts(inputs []AuditArtifact, stagingDir string) ([]AuditArt return result, nil } +// sortAuditArtifactsByAuditorID orders the supplied reports by the auditor ID +// each record names. The records are only read here; authentication and +// enrollment checks run in verifyPassingAudits on the bundled copies. +func sortAuditArtifactsByAuditorID(inputs []AuditArtifact) ([]AuditArtifact, error) { + type keyed struct { + artifact AuditArtifact + auditorID string + } + entries := make([]keyed, len(inputs)) + for index, input := range inputs { + recordBytes, err := readRegularFile(input.RecordPath) + if err != nil { + return nil, fmt.Errorf("audit %d: %w", index, err) + } + var record AuditRecord + if err := UnmarshalCanonical(recordBytes, &record); err != nil { + return nil, fmt.Errorf("audit %d: %w", index, err) + } + entries[index] = keyed{artifact: input, auditorID: record.AuditorID} + } + slices.SortStableFunc(entries, func(a, b keyed) int { + return strings.Compare(a.auditorID, b.auditorID) + }) + sorted := make([]AuditArtifact, len(entries)) + for index, entry := range entries { + sorted[index] = entry.artifact + } + return sorted, nil +} + func bundledAuditsForTranscript(keysDir string, refs []ArtifactRef) ([]AuditArtifact, error) { result := make([]AuditArtifact, len(refs)) for index, ref := range refs { diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index 6644037..6de302a 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -24,7 +24,14 @@ const ( ProductionDecisionSchema = "proof-tool-mpc-production-decision-v1" ProductionDecisionDraftSchema = "proof-tool-mpc-production-decision-draft-v1" ProductionDecisionSignatureSchema = "proof-tool-mpc-production-decision-signature-v1" - MaxProductionReleaseArtifacts = 4096 + // MaxProductionReleaseArtifacts must admit the largest release tree the + // earlier layers can produce, or a fully valid signed release strands at + // decision preparation. Upper bound of the evidence a bundle may reference: + // up to 128 governance records with up to 128 evidence artifacts each + // (~16.5k), plus enrollments (128 x 3), witness receipts (32 x 2 phases x 2 + // files), mirror receipts (8 x 20 heads x 2 phases), relay evidence, audits, + // and the fixed candidate set — comfortably under 32768. + MaxProductionReleaseArtifacts = 32768 ) type ProductionDecisionOutcome string @@ -1256,7 +1263,7 @@ func decisionSignerIdentity( return identity, nil } } - return Identity{}, errors.New("auditor decision signature is not from either audit bound by the decision") + return Identity{}, errors.New("auditor decision signature is not from any audit bound by the decision") default: return Identity{}, fmt.Errorf("unsupported decision signer role %q", role) } diff --git a/internal/mpcceremony/decision_test.go b/internal/mpcceremony/decision_test.go index c227d06..5276f3b 100644 --- a/internal/mpcceremony/decision_test.go +++ b/internal/mpcceremony/decision_test.go @@ -153,7 +153,7 @@ func TestSignedReleaseInventorySupportsTwentyPartyOperationalScale(t *testing.T) } } if _, err := NewSignedReleaseEvidence(input); err == nil || - !strings.Contains(err.Error(), "4096") { + !strings.Contains(err.Error(), fmt.Sprint(MaxProductionReleaseArtifacts)) { t.Fatalf("oversized release inventory error = %v", err) } } diff --git a/internal/mpcceremony/definition_gate_test.go b/internal/mpcceremony/definition_gate_test.go new file mode 100644 index 0000000..b73144f --- /dev/null +++ b/internal/mpcceremony/definition_gate_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "fmt" + "strings" + "testing" +) + +// TestDefinitionBoundsAuditorsToTranscriptCapacity pins the M2 gate sweep +// fix: the final transcript records audits in a list capped at MaxAuditors, +// so enrollment must reject what the transcript cannot record instead of +// letting release sign discover it after every audit has been performed. +func TestDefinitionBoundsAuditorsToTranscriptCapacity(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + for index := len(definition.Auditors); index < MaxAuditors+1; index++ { + definition.Auditors = append(definition.Auditors, adversarialIdentity( + t, + fmt.Sprintf("auditor-%02d", index+1), + byte(0x40+index), + )) + } + if _, err := FinalizeCeremonyDefinition(definition); err == nil || + !strings.Contains(err.Error(), "exceed maximum") { + t.Fatalf("definition with %d auditors error = %v, want transcript-capacity rejection", MaxAuditors+1, err) + } + definition.Auditors = definition.Auditors[:MaxAuditors] + if _, err := FinalizeCeremonyDefinition(definition); err != nil { + t.Fatalf("definition with exactly %d auditors rejected: %v", MaxAuditors, err) + } +} diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index b908030..4eb29a5 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -57,6 +57,12 @@ const ( ModeProduction = "production" MaxParticipants = 20 + // MaxAuditors bounds enrolled auditors. The final transcript stores audit + // reports in an artifact list capped at MaxParticipants entries, so the + // bound must be enforced at enrollment too: without it a ceremony could + // enroll more auditors than the transcript can record and discover that + // only at release, after every audit had already been performed. + MaxAuditors = MaxParticipants ) type Phase string From 9c0f6dcac3b206a06c6266fbe02b706a2e3fa5d8 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 09:03:16 +0000 Subject: [PATCH 20/42] Rename the audits gate before any record freezes the old name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The gate label "two-independent-audits" said "two" while the rule it names now accepts two or more. The label is part of the signed decision schema, so it is only renamable while no signed decision record exists — none does, in this tree or any published artifact. Rename it to "independent-audits" now, before the first production decision makes it permanent. --- internal/mpcceremony/decision.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index 6de302a..c51f871 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -54,7 +54,7 @@ type ProductionGate string const ( GateSignedRelease ProductionGate = "signed-release" GateOperationalEvidence ProductionGate = "operational-evidence" - GateIndependentAudits ProductionGate = "two-independent-audits" + GateIndependentAudits ProductionGate = "independent-audits" GateExternalAudit ProductionGate = "third-party-security-audit" GateK21Rehearsal ProductionGate = "exact-k21-rehearsal" GateMainnetDeploymentPlan ProductionGate = "mainnet-deployment-plan" From 61a4a1e7e6dc1c72b181a268ef18223ff4c8c0b9 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 09:12:16 +0000 Subject: [PATCH 21/42] Add brokerless MPC ceremony inspection and receipts --- cmd/mpc-ceremony/cli_test.go | 174 +++++++++ cmd/mpc-ceremony/executor.go | 12 + cmd/mpc-ceremony/inspect.go | 181 +++++++++ cmd/mpc-ceremony/inspect_test.go | 368 ++++++++++++++++++ cmd/mpc-ceremony/integration_test.go | 28 ++ cmd/mpc-ceremony/main.go | 14 +- cmd/mpc-ceremony/ops.go | 203 +++++++++- cmd/mpc-ceremony/parse.go | 189 +++++++++ cmd/mpc-ceremony/public_witness_ops_test.go | 267 +++++++++++++ cmd/mpc-ceremony/types.go | 192 +++++++-- cmd/mpc-ceremony/usage.go | 81 +++- internal/mpcceremony/inspection.go | 142 +++++++ internal/mpcceremony/inspection_test.go | 241 ++++++++++++ .../mirror_receipt_prepare_test.go | 139 +++++++ internal/mpcceremony/operational.go | 127 +++++- internal/mpcceremony/operational_builder.go | 193 +++++++++ internal/mpcceremony/operational_bundle.go | 30 +- internal/mpcceremony/workflow.go | 51 ++- 18 files changed, 2523 insertions(+), 109 deletions(-) create mode 100644 cmd/mpc-ceremony/inspect.go create mode 100644 cmd/mpc-ceremony/inspect_test.go create mode 100644 cmd/mpc-ceremony/public_witness_ops_test.go create mode 100644 internal/mpcceremony/inspection.go create mode 100644 internal/mpcceremony/inspection_test.go create mode 100644 internal/mpcceremony/mirror_receipt_prepare_test.go diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 8952b50..0f30b82 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -334,6 +334,41 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { ), command: CommandDecisionVerify, }, + { + name: "ops prepare public witness receipt", + args: joinArgs( + []string{"ops", "prepare-public-witness-receipt"}, + ceremonyTrust, + []string{ + "--transcript-root", "transcript", + "--closure", "transcript/phase1/closure/record.json", + "--closure-signature", "transcript/phase1/closure/record.sig", + "--witness-enrollment", "ops/witness-enrollment.json", + "--witness-enrollment-signature", "ops/witness-enrollment.sig", + "--publication-location", "https://witness.example/phase1/closure", + "--observed-at", "2026-08-18T12:00:00Z", + "--out-dir", "ops/witness-export", + }, + ), + command: CommandOpsPreparePublicWitnessReceipt, + }, + { + name: "ops prepare mirror receipt", + args: joinArgs( + []string{"ops", "prepare-mirror-receipt"}, + ceremonyTrust, + []string{ + "--draft", "ops/mirror-draft.json", + "--transcript-root", "transcript", + "--chain", "transcript/phase1/chain-0001.json", + "--chain-signature", "transcript/phase1/chain-0001.sig", + "--mirror-enrollment", "ops/mirror-enrollment.json", + "--mirror-enrollment-signature", "ops/mirror-enrollment.sig", + "--out-dir", "ops/mirror-export", + }, + ), + command: CommandOpsPrepareMirrorReceipt, + }, { name: "ops export signing", args: joinArgs( @@ -377,6 +412,45 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { ), command: CommandOpsVerify, }, + { + name: "inspect definition", + args: joinArgs([]string{"inspect", "definition"}, ceremonyTrust), + command: CommandInspectDefinition, + }, + { + name: "inspect chain", + args: joinArgs( + []string{"inspect", "chain"}, + ceremonyTrust, + []string{ + "--transcript-root", "transcript", + "--chain", "transcript/phase1/chain-0001.json", + "--chain-signature", "transcript/phase1/chain-0001.sig", + }, + ), + command: CommandInspectChain, + }, + { + name: "inspect participant", + args: joinArgs( + []string{"inspect", "participant"}, + ceremonyTrust, + []string{"--participant-signing-key", "keys/participant-01.private.hex"}, + ), + command: CommandInspectParticipant, + }, + { + name: "inspect enrollment", + args: joinArgs( + []string{"inspect", "enrollment"}, + ceremonyTrust, + []string{ + "--enrollment", "ops/witness-enrollment.json", + "--enrollment-signature", "ops/witness-enrollment.sig", + }, + ), + command: CommandInspectEnrollment, + }, } for _, test := range tests { @@ -411,6 +485,63 @@ func TestParseInvocationRejectsMissingExplicitPaths(t *testing.T) { } } +func TestBrokerlessCommandsRequireSecurityCriticalInputs(t *testing.T) { + t.Parallel() + tests := []struct { + name string + args []string + want string + }{ + { + name: "participant key", + args: []string{ + "inspect", "participant", + "--ceremony", "ceremony.json", + "--ceremony-signature", "ceremony.sig", + "--coordinator-public-key-file", "coordinator.pub", + }, + want: "--participant-signing-key", + }, + { + name: "enrollment signature", + args: []string{ + "inspect", "enrollment", + "--ceremony", "ceremony.json", + "--ceremony-signature", "ceremony.sig", + "--coordinator-public-key-file", "coordinator.pub", + "--enrollment", "witness.json", + }, + want: "--enrollment-signature", + }, + { + name: "witness observation", + args: []string{ + "ops", "prepare-public-witness-receipt", + "--ceremony", "ceremony.json", + "--ceremony-signature", "ceremony.sig", + "--coordinator-public-key-file", "coordinator.pub", + "--transcript-root", "transcript", + "--closure", "closure.json", + "--closure-signature", "closure.sig", + "--witness-enrollment", "witness.json", + "--witness-enrollment-signature", "witness.sig", + "--publication-location", "https://witness.example/closure", + "--out-dir", "output", + }, + want: "--observed-at", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := parseInvocation(test.args) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want missing %s", err, test.want) + } + }) + } +} + func TestParseInvocationRejectsStreamsURLsAndForce(t *testing.T) { t.Parallel() @@ -793,6 +924,49 @@ func TestRunCLIErrorOutputRedactsCallerControlledValues(t *testing.T) { } } +func TestDiagnosticRedactionRecognizesInspectionAndReceiptCommands(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + args []string + commandIndex int + valueIndex int + }{ + { + name: "participant inspection", + args: []string{"--format=json", "inspect", "participant", "--participant-signing-key", "secret.key"}, + commandIndex: 1, + valueIndex: 4, + }, + { + name: "enrollment inspection", + args: []string{"inspect", "enrollment", "--enrollment", "enrollment.json"}, + commandIndex: 0, + valueIndex: 3, + }, + { + name: "public witness receipt", + args: []string{"ops", "prepare-public-witness-receipt", "--publication-location", "https://private.example/closure"}, + commandIndex: 0, + valueIndex: 3, + }, + } { + t.Run(test.name, func(t *testing.T) { + safe := identifyCLICommandArguments(test.args) + if _, ok := safe[test.commandIndex]; !ok { + t.Fatal("top-level command is not recognized as diagnostic-safe") + } + if _, ok := safe[test.commandIndex+1]; !ok { + t.Fatal("subcommand is not recognized as diagnostic-safe") + } + if _, ok := safe[test.valueIndex]; ok { + t.Fatal("caller-controlled flag value is incorrectly diagnostic-safe") + } + }) + } +} + func TestRunCLIRejectsHelpOutputFailure(t *testing.T) { t.Parallel() diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 9b1d708..c6bb654 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -67,6 +67,10 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeReleaseSign(invocation.Options.(ReleaseSignOptions)) case CommandReleaseVerify: return executeReleaseVerify(invocation.Options.(ReleaseVerifyOptions)) + case CommandOpsPreparePublicWitnessReceipt: + return executeOpsPreparePublicWitnessReceipt(invocation.Options.(OpsPreparePublicWitnessReceiptOptions)) + case CommandOpsPrepareMirrorReceipt: + return executeOpsPrepareMirrorReceipt(invocation.Options.(OpsPrepareMirrorReceiptOptions)) case CommandOpsExportSigning: return executeOpsExportSigning(invocation.Options.(OpsExportSigningOptions)) case CommandOpsImportSig: @@ -79,6 +83,14 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeDecisionSign(invocation.Options.(DecisionSignOptions)) case CommandDecisionVerify: return executeDecisionVerify(invocation.Options.(DecisionVerifyOptions)) + case CommandInspectDefinition: + return executeInspectDefinition(invocation.Options.(InspectDefinitionOptions)) + case CommandInspectChain: + return executeInspectChain(invocation.Options.(InspectChainOptions)) + case CommandInspectParticipant: + return executeInspectParticipant(invocation.Options.(InspectParticipantOptions)) + case CommandInspectEnrollment: + return executeInspectEnrollment(invocation.Options.(InspectEnrollmentOptions)) default: return CommandResult{}, fmt.Errorf("%w: %s", errExecutorNotWired, invocation.Command) } diff --git a/cmd/mpc-ceremony/inspect.go b/cmd/mpc-ceremony/inspect.go new file mode 100644 index 0000000..60ad5a8 --- /dev/null +++ b/cmd/mpc-ceremony/inspect.go @@ -0,0 +1,181 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + + "proof-tool/internal/mpcceremony" +) + +const ( + definitionInspectionSchema = "proof-tool-mpc-definition-inspection-v1" + chainInspectionSchema = "proof-tool-mpc-chain-inspection-v1" + participantInspectionSchema = "proof-tool-mpc-participant-inspection-v1" + enrollmentInspectionSchema = "proof-tool-mpc-enrollment-inspection-v1" +) + +func executeInspectDefinition(options InspectDefinitionOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options) + if err != nil { + return CommandResult{}, err + } + inspection := inspectDefinition(trusted.Definition) + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: "authenticated ceremony definition", + DefinitionInspection: &inspection, + }, nil +} + +func executeInspectChain(options InspectChainOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + chain, err := mpcceremony.LoadSignedChain(trusted, mpcceremony.PhaseTranscriptPaths{ + RootDir: options.TranscriptRoot, + ChainPath: options.ChainPath, + ChainSignaturePath: options.ChainSignaturePath, + }) + if err != nil { + return CommandResult{}, err + } + inspection := inspectChain(chain) + return CommandResult{ + CeremonyID: chain.CeremonyID, + Phase: string(chain.Phase), + Sequence: len(chain.Records), + Summary: fmt.Sprintf("authenticated %s chain with %d accepted contributions", chain.Phase, len(chain.Records)), + ChainInspection: &inspection, + }, nil +} + +func executeInspectParticipant(options InspectParticipantOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + match, err := mpcceremony.InspectParticipantSigningKey( + trusted.Definition, + options.ParticipantSigningKey, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("participant signing key: %w", err) + } + inspection := ParticipantInspection{ + Schema: participantInspectionSchema, + CeremonyID: trusted.Definition.CeremonyID, + ParticipantID: match.ParticipantID, + KeyID: match.KeyID, + PublicKeyFingerprint: match.PublicKeyFingerprint, + Phase1Position: cloneUint8Pointer(match.Phase1Position), + Phase2Position: cloneUint8Pointer(match.Phase2Position), + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: "matched existing signing key to authenticated participant roster", + ParticipantInspection: &inspection, + }, nil +} + +func executeInspectEnrollment(options InspectEnrollmentOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + recordBytes, err := readRegularOperationalFile(options.EnrollmentPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + signatureBytes, err := readRegularOperationalFile(options.EnrollmentSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + enrollment, err := mpcceremony.VerifyEnrollmentProofOfPossession( + trusted.Definition, + definitionBytes, + recordBytes, + signatureBytes, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("enrollment proof of possession: %w", err) + } + inspection := EnrollmentInspection{ + Schema: enrollmentInspectionSchema, + CeremonyID: enrollment.CeremonyID, + Identity: enrollment.Identity, + Role: enrollment.Role, + RoleIndex: enrollment.RoleIndex, + EnrolledAt: enrollment.EnrolledAt, + IndependenceDisclosure: enrollment.IndependenceDisclosure, + } + return CommandResult{ + CeremonyID: enrollment.CeremonyID, + Summary: "authenticated operational enrollment and proof of possession", + EnrollmentInspection: &inspection, + }, nil +} + +func cloneUint8Pointer(value *uint8) *uint8 { + if value == nil { + return nil + } + copy := *value + return © +} + +func loadInspectionCeremony(options InspectDefinitionOptions) (*mpcceremony.TrustedCeremony, error) { + return mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }) +} + +func inspectDefinition(definition mpcceremony.CeremonyDefinition) DefinitionInspection { + return DefinitionInspection{ + Schema: definitionInspectionSchema, + CeremonyID: definition.CeremonyID, + Mode: definition.Mode, + Phase1Participants: append([]string(nil), definition.Phase1Policy.Participants...), + Phase2Participants: append([]string(nil), definition.Phase2Policy.Participants...), + R1CS: definition.Circuit.R1CS, + } +} + +func inspectChain(chain mpcceremony.Chain) ChainInspection { + artifacts := make([]mpcceremony.ArtifactRef, 0, 1+6*len(chain.Records)) + artifacts = append(artifacts, chain.Genesis) + records := make([]ChainRecordInspection, 0, len(chain.Records)) + for _, record := range chain.Records { + recordArtifacts := []mpcceremony.ArtifactRef{ + record.OutputPayload, + record.Attestation, + record.AttestationSignature, + record.Erasure, + record.ErasureSignature, + record.Verification, + } + artifacts = append(artifacts, recordArtifacts...) + records = append(records, ChainRecordInspection{ + Index: record.Index, + RecordID: record.RecordID, + ParticipantID: record.ParticipantID, + Artifacts: recordArtifacts, + }) + } + return ChainInspection{ + Schema: chainInspectionSchema, + CeremonyID: chain.CeremonyID, + Phase: chain.Phase, + AcceptedCount: len(chain.Records), + Artifacts: artifacts, + Records: records, + } +} diff --git a/cmd/mpc-ceremony/inspect_test.go b/cmd/mpc-ceremony/inspect_test.go new file mode 100644 index 0000000..c80913e --- /dev/null +++ b/cmd/mpc-ceremony/inspect_test.go @@ -0,0 +1,368 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "proof-tool/internal/mpcceremony" +) + +func TestInspectCommandsAuthenticateSignedDefinitionAndChain(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + definitionBytes, definitionSignature, err := mpcceremony.SignRecord( + definition, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + phaseID, err := mpcceremony.ComputePhaseID( + definition.CeremonyID, + mpcceremony.Phase1, + definition.Phase1Genesis, + "", + ) + if err != nil { + t.Fatal(err) + } + chain, err := mpcceremony.NewChain( + definition.CeremonyID, + mpcceremony.Phase1, + phaseID, + definition.Phase1Genesis, + ) + if err != nil { + t.Fatal(err) + } + chainBytes, chainSignature, err := mpcceremony.SignRecord( + chain, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + + ceremonyPath := filepath.Join(root, "ceremony.json") + ceremonySignaturePath := filepath.Join(root, "ceremony.sig") + coordinatorPublicKeyPath := filepath.Join(root, "coordinator-public-key.hex") + chainPath := filepath.Join(root, "phase1", "chain-0000.json") + chainSignaturePath := filepath.Join(root, "phase1", "chain-0000.sig") + if err := os.MkdirAll(filepath.Dir(chainPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, ceremonyPath, definitionBytes, 0o600) + writeDecisionTestFile(t, ceremonySignaturePath, definitionSignature, 0o600) + writeDecisionTestFile(t, coordinatorPublicKeyPath, []byte(definition.Coordinator.Ed25519PublicKeyHex+"\n"), 0o600) + writeDecisionTestFile(t, chainPath, chainBytes, 0o600) + writeDecisionTestFile(t, chainSignaturePath, chainSignature, 0o600) + + trustArgs := []string{ + "--ceremony", ceremonyPath, + "--ceremony-signature", ceremonySignaturePath, + "--coordinator-public-key-file", coordinatorPublicKeyPath, + } + tests := []struct { + name string + args []string + command Command + check func(CommandResult) bool + }{ + { + name: "definition", + args: append([]string{"--format", "json", "inspect", "definition"}, trustArgs...), + command: CommandInspectDefinition, + check: func(result CommandResult) bool { + return result.DefinitionInspection != nil && + result.DefinitionInspection.CeremonyID == definition.CeremonyID && + reflect.DeepEqual(result.DefinitionInspection.Phase1Participants, definition.Phase1Policy.Participants) + }, + }, + { + name: "chain", + args: append( + append([]string{"--format", "json", "inspect", "chain"}, trustArgs...), + "--transcript-root", root, + "--chain", chainPath, + "--chain-signature", chainSignaturePath, + ), + command: CommandInspectChain, + check: func(result CommandResult) bool { + return result.ChainInspection != nil && + result.ChainInspection.CeremonyID == definition.CeremonyID && + result.ChainInspection.AcceptedCount == 0 && + len(result.ChainInspection.Artifacts) == 1 + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), test.args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.OK || result.Command != test.command || !test.check(result) { + t.Fatalf("result = %#v", result) + } + }) + } + + writeDecisionTestFile(t, chainPath, append(chainBytes, '\n'), 0o600) + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), tests[1].args, &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatalf("tampered chain was accepted: stdout = %q", stdout.String()) + } +} + +func TestInspectParticipantMatchesRosterPositionsWithoutExposingPrivateKey(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + definition.Mode = mpcceremony.ModeRehearsal + definition.Phase2Policy.Participants = []string{"participant-01", "participant-03"} + definition.Phase2Policy.Minimum = 2 + var err error + definition, err = mpcceremony.FinalizeCeremonyDefinition(definition) + if err != nil { + t.Fatal(err) + } + trustArgs := writeInspectionTrustFixture(t, root, definition, coordinatorKey) + participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x12}, ed25519.SeedSize)) + seedHex := hex.EncodeToString(participantKey.Seed()) + keyPath := filepath.Join(root, "participant-02.private.hex") + writeDecisionTestFile(t, keyPath, []byte(seedHex+"\n"), 0o600) + + args := append( + append([]string{"--format", "json", "inspect", "participant"}, trustArgs...), + "--participant-signing-key", keyPath, + ) + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), seedHex) || strings.Contains(stdout.String(), keyPath) { + t.Fatal("participant inspection output exposed private key material or its path") + } + if !strings.Contains(stdout.String(), `"phase2_position":null`) { + t.Fatalf("absent phase position was not explicit null: %s", stdout.String()) + } + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + inspection := result.ParticipantInspection + if inspection == nil || inspection.Schema != participantInspectionSchema || + inspection.ParticipantID != "participant-02" || + inspection.KeyID != definition.Roster[1].Identity.KeyID || + inspection.PublicKeyFingerprint != definition.Roster[1].Identity.PublicKeyFingerprint || + inspection.Phase1Position == nil || *inspection.Phase1Position != 2 || + inspection.Phase2Position != nil { + t.Fatalf("participant inspection = %#v", inspection) + } +} + +func TestInspectEnrollmentAuthenticatesWitnessAndMirrorProofs(t *testing.T) { + for _, test := range []struct { + role mpcceremony.EnrollmentRole + id string + fill byte + }{ + {role: mpcceremony.EnrollmentPublicWitness, id: "public-witness-01", fill: 0x91}, + {role: mpcceremony.EnrollmentMirrorOperator, id: "mirror-operator-01", fill: 0xa1}, + } { + t.Run(string(test.role), func(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + trustArgs := writeInspectionTrustFixture(t, root, definition, coordinatorKey) + record, recordBytes, signatureBytes, _ := commandSignedExternalEnrollment( + t, definition, test.role, test.id, test.fill, + ) + recordPath := filepath.Join(root, test.id+".json") + signaturePath := filepath.Join(root, test.id+".sig") + writeDecisionTestFile(t, recordPath, recordBytes, 0o600) + writeDecisionTestFile(t, signaturePath, signatureBytes, 0o600) + args := append( + append([]string{"--format", "json", "inspect", "enrollment"}, trustArgs...), + "--enrollment", recordPath, + "--enrollment-signature", signaturePath, + ) + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + inspection := result.EnrollmentInspection + if inspection == nil || inspection.Schema != enrollmentInspectionSchema || + inspection.CeremonyID != definition.CeremonyID || inspection.Identity != record.Identity || + inspection.Role != test.role || inspection.RoleIndex != 1 || + inspection.IndependenceDisclosure != record.IndependenceDisclosure { + t.Fatalf("enrollment inspection = %#v", inspection) + } + + writeDecisionTestFile(t, recordPath, append(recordBytes, '\n'), 0o600) + stdout.Reset() + stderr.Reset() + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatal("altered enrollment unexpectedly accepted") + } + }) + } +} + +func writeInspectionTrustFixture( + t *testing.T, + root string, + definition mpcceremony.CeremonyDefinition, + coordinatorKey ed25519.PrivateKey, +) []string { + t.Helper() + definitionBytes, signatureBytes, err := mpcceremony.SignRecord( + definition, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + ceremonyPath := filepath.Join(root, "ceremony.json") + signaturePath := filepath.Join(root, "ceremony.sig") + publicKeyPath := filepath.Join(root, "coordinator-public-key.hex") + writeDecisionTestFile(t, ceremonyPath, definitionBytes, 0o600) + writeDecisionTestFile(t, signaturePath, signatureBytes, 0o600) + writeDecisionTestFile(t, publicKeyPath, []byte(definition.Coordinator.Ed25519PublicKeyHex+"\n"), 0o600) + return []string{ + "--ceremony", ceremonyPath, + "--ceremony-signature", signaturePath, + "--coordinator-public-key-file", publicKeyPath, + } +} + +func commandSignedExternalEnrollment( + t *testing.T, + definition mpcceremony.CeremonyDefinition, + role mpcceremony.EnrollmentRole, + id string, + fill byte, +) (mpcceremony.EnrollmentRecord, []byte, []byte, ed25519.PrivateKey) { + t.Helper() + privateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{fill}, ed25519.SeedSize)) + identity, err := mpcceremony.NewIdentity( + id, + "Test "+id, + id+"-key", + privateKey.Public().(ed25519.PublicKey), + ) + if err != nil { + t.Fatal(err) + } + definitionBytes, err := mpcceremony.MarshalCanonical(definition) + if err != nil { + t.Fatal(err) + } + record, err := mpcceremony.NewEnrollmentRecord( + definition, + definitionBytes, + identity, + role, + 1, + mpcceremony.ArtifactRef{ + Name: "disclosures/" + id + ".json", + Digest: mpcceremony.NewDigest([]byte("independent " + id)), + }, + "2026-07-23T12:00:01Z", + ) + if err != nil { + t.Fatal(err) + } + recordBytes, signatureBytes, err := mpcceremony.SignRecord(record, identity.KeyID, privateKey) + if err != nil { + t.Fatal(err) + } + return record, recordBytes, signatureBytes, privateKey +} + +func TestInspectDefinitionProjectsRelayView(t *testing.T) { + definition := mpcceremony.CeremonyDefinition{ + CeremonyID: "ceremony-id", + Mode: mpcceremony.ModeProduction, + Phase1Policy: mpcceremony.PhasePolicy{ + Participants: []string{"p1", "p2"}, + }, + Phase2Policy: mpcceremony.PhasePolicy{ + Participants: []string{"p2"}, + }, + Circuit: mpcceremony.CircuitBinding{ + R1CS: mpcceremony.ArtifactRef{Name: "ownership-destination.ccs"}, + }, + } + + got := inspectDefinition(definition) + if got.Schema != definitionInspectionSchema || got.CeremonyID != definition.CeremonyID || got.Mode != definition.Mode { + t.Fatalf("inspection identity = %#v", got) + } + if !reflect.DeepEqual(got.Phase1Participants, []string{"p1", "p2"}) || + !reflect.DeepEqual(got.Phase2Participants, []string{"p2"}) { + t.Fatalf("inspection schedules = %#v / %#v", got.Phase1Participants, got.Phase2Participants) + } + if got.R1CS != definition.Circuit.R1CS { + t.Fatalf("inspection r1cs = %#v", got.R1CS) + } + + definition.Phase1Policy.Participants[0] = "changed" + if got.Phase1Participants[0] != "p1" { + t.Fatal("inspection retained mutable definition schedule storage") + } +} + +func TestInspectChainProjectsStableArtifactOrder(t *testing.T) { + ref := func(name string) mpcceremony.ArtifactRef { return mpcceremony.ArtifactRef{Name: name} } + chain := mpcceremony.Chain{ + CeremonyID: "ceremony-id", + Phase: mpcceremony.Phase1, + Genesis: ref("genesis"), + Records: []mpcceremony.ChainRecord{{ + Index: 1, + RecordID: "record-id", + ParticipantID: "p1", + OutputPayload: ref("output"), + Attestation: ref("attestation"), + AttestationSignature: ref("attestation.sig"), + Erasure: ref("erasure"), + ErasureSignature: ref("erasure.sig"), + Verification: ref("verification"), + }}, + } + + got := inspectChain(chain) + if got.Schema != chainInspectionSchema || got.AcceptedCount != 1 || len(got.Records) != 1 { + t.Fatalf("inspection = %#v", got) + } + wantNames := []string{"genesis", "output", "attestation", "attestation.sig", "erasure", "erasure.sig", "verification"} + for index, want := range wantNames { + if got.Artifacts[index].Name != want { + t.Fatalf("artifact %d = %q, want %q", index, got.Artifacts[index].Name, want) + } + } + if !reflect.DeepEqual(got.Records[0].Artifacts, got.Artifacts[1:]) { + t.Fatalf("record artifacts = %#v, want %#v", got.Records[0].Artifacts, got.Artifacts[1:]) + } +} diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index b6503f6..efac782 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -42,7 +42,14 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"decision", "prepare"}, {"decision", "sign"}, {"decision", "verify"}, + {"inspect"}, + {"inspect", "definition"}, + {"inspect", "chain"}, + {"inspect", "participant"}, + {"inspect", "enrollment"}, {"ops"}, + {"ops", "prepare-public-witness-receipt"}, + {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, {"ops", "import-signature"}, {"ops", "verify"}, @@ -111,6 +118,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--destroyed-at", "--decision", "--draft", + "--enrollment", + "--enrollment-signature", "--evidence-root", "--coordinator-key-id", "--coordinator-public-key-file", @@ -118,16 +127,21 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--environment", "--finalized-at", "--format", + "--full", "--key-version", "--keys-dir", "--manifest-public-key-file", + "--mirror-enrollment", + "--mirror-enrollment-signature", "--mode", + "--observed-at", "--out", "--out-dir", "--published-at", "--participant-id", "--participant-signing-key", "--prepared-at", + "--publication-location", "--public-evidence", "--participants", "--phase1-beacon", @@ -168,6 +182,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--signature-key-id", "--transcript-dir", "--transcript-root", + "--witness-enrollment", + "--witness-enrollment-signature", "--accepted-at", "--contributed-at", } @@ -197,6 +213,12 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandDecisionPrepare, Options: DecisionPrepareOptions{}}, {Command: CommandDecisionSign, Options: DecisionSignOptions{}}, {Command: CommandDecisionVerify, Options: DecisionVerifyOptions{}}, + {Command: CommandOpsPreparePublicWitnessReceipt, Options: OpsPreparePublicWitnessReceiptOptions{}}, + {Command: CommandOpsPrepareMirrorReceipt, Options: OpsPrepareMirrorReceiptOptions{}}, + {Command: CommandInspectDefinition, Options: InspectDefinitionOptions{}}, + {Command: CommandInspectChain, Options: InspectChainOptions{}}, + {Command: CommandInspectParticipant, Options: InspectParticipantOptions{}}, + {Command: CommandInspectEnrollment, Options: InspectEnrollmentOptions{}}, } for _, invocation := range tests { t.Run(string(invocation.Command), func(t *testing.T) { @@ -229,6 +251,12 @@ func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { {"release", "verify"}, {"decision", "sign"}, {"decision", "verify"}, + {"inspect", "definition"}, + {"inspect", "chain"}, + {"inspect", "participant"}, + {"inspect", "enrollment"}, + {"ops", "prepare-public-witness-receipt"}, + {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, {"ops", "import-signature"}, {"ops", "verify"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 4220212..1d1c687 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -239,8 +239,8 @@ func identifyCLICommandArguments(args []string) map[int]struct{} { command: topLevel := map[string]struct{}{ - "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, "inspect": {}, - "ops": {}, "phase1": {}, "phase2": {}, "release": {}, + "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, + "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "release": {}, } if _, ok := topLevel[args[index]]; !ok { return safe @@ -257,8 +257,14 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "ops": {"export-signing": {}, "help": {}, "import-signature": {}, "verify": {}}, - "release": {"help": {}, "sign": {}, "verify": {}}, + "inspect": { + "chain": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, + }, + "ops": { + "export-signing": {}, "help": {}, "import-signature": {}, + "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "verify": {}, + }, + "release": {"help": {}, "sign": {}, "verify": {}}, } allowed, hasSubcommands := subcommands[args[index]] if hasSubcommands && index+1 < len(args) { diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index 7d3ecae..c8ec8ad 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -18,6 +18,161 @@ import ( const maxOperationalRecordBytes = 16 << 20 +func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceiptOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }) + if err != nil { + return CommandResult{}, err + } + closure, closureName, err := mpcceremony.LoadSignedCloseExact( + trusted, + options.TranscriptRoot, + options.ClosurePath, + options.ClosureSignaturePath, + ) + if err != nil { + return CommandResult{}, err + } + enrollmentBytes, err := readRegularOperationalFile(options.WitnessEnrollmentPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + enrollmentSignatureBytes, err := readRegularOperationalFile(options.WitnessEnrollmentSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + enrollment, err := mpcceremony.VerifyEnrollmentProofOfPossession( + trusted.Definition, + definitionBytes, + enrollmentBytes, + enrollmentSignatureBytes, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("witness enrollment proof of possession: %w", err) + } + _, canonical, err := mpcceremony.PreparePublicWitnessReceipt( + trusted.Definition, + closure.Record, + closure.RecordBytes, + enrollment, + closureName, + options.PublicationLocation, + options.ObservedAt, + ) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordPublicWitness, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Phase: string(closure.Record.Phase), + Summary: "validated human-claimed publication observation and exported canonical public-witness receipt for offline signing", + Outputs: map[string]string{ + "canonical": canonicalPath, + "signing_request": requestPath, + }, + }, nil +} + +func executeOpsPrepareMirrorReceipt(options OpsPrepareMirrorReceiptOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }) + if err != nil { + return CommandResult{}, err + } + chain, chainPrefix, err := mpcceremony.LoadSignedChainExact(trusted, mpcceremony.PhaseTranscriptPaths{ + RootDir: options.TranscriptRoot, + ChainPath: options.ChainPath, + ChainSignaturePath: options.ChainSignaturePath, + }) + if err != nil { + return CommandResult{}, err + } + draftBytes, err := readRegularOperationalFile(options.DraftPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + draft, err := mpcceremony.ParseMirrorReceiptDraft(draftBytes) + if err != nil { + return CommandResult{}, fmt.Errorf("mirror receipt draft: %w", err) + } + enrollmentBytes, err := readRegularOperationalFile(options.MirrorEnrollmentPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + enrollmentSignatureBytes, err := readRegularOperationalFile(options.MirrorEnrollmentSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + enrollment, err := mpcceremony.VerifyEnrollmentProofOfPossession( + trusted.Definition, + definitionBytes, + enrollmentBytes, + enrollmentSignatureBytes, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("mirror enrollment proof of possession: %w", err) + } + _, canonical, err := mpcceremony.PrepareImmutableMirrorReceipt( + trusted.Definition, + chain, + chainPrefix, + draft, + enrollment, + ) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordMirrorReceipt, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Phase: string(chain.Phase), + Sequence: int(draft.Index), + Summary: "authenticated relay draft and exported exact canonical mirror receipt bytes for offline signing", + Outputs: map[string]string{ + "draft": options.DraftPath, + "canonical": canonicalPath, + "signing_request": requestPath, + }, + }, nil +} + func executeOpsExportSigning(options OpsExportSigningOptions) (result CommandResult, err error) { recordType := mpcceremony.OperationalRecordType(options.RecordType) canonical, record, trusted, err := loadBoundOperationalRecord( @@ -46,38 +201,46 @@ func executeOpsExportSigning(options OpsExportSigningOptions) (result CommandRes return CommandResult{}, err } - if err := os.Mkdir(options.OutDir, 0o700); err != nil { - return CommandResult{}, fmt.Errorf("create fresh signing export directory: %w", err) + canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: "exported exact canonical operational record bytes and digest for offline signing", + Outputs: map[string]string{ + "canonical": canonicalPath, + "signing_request": requestPath, + }, + }, nil +} + +func writeOperationalSigningExport(outDir string, canonical, request []byte) (canonicalPath, requestPath string, err error) { + if err := os.Mkdir(outDir, 0o700); err != nil { + return "", "", fmt.Errorf("create fresh signing export directory: %w", err) } complete := false defer func() { if complete { return } - _ = os.Remove(filepath.Join(options.OutDir, "canonical.json")) - _ = os.Remove(filepath.Join(options.OutDir, "signing-request.json")) - _ = os.Remove(options.OutDir) + _ = os.Remove(filepath.Join(outDir, "canonical.json")) + _ = os.Remove(filepath.Join(outDir, "signing-request.json")) + _ = os.Remove(outDir) }() - canonicalPath := filepath.Join(options.OutDir, "canonical.json") - requestPath := filepath.Join(options.OutDir, "signing-request.json") + canonicalPath = filepath.Join(outDir, "canonical.json") + requestPath = filepath.Join(outDir, "signing-request.json") if err := writeFreshOperationalFile(canonicalPath, canonical, 0o600); err != nil { - return CommandResult{}, err + return "", "", err } - if err := writeFreshOperationalFile(requestPath, requestBytes, 0o600); err != nil { - return CommandResult{}, err + if err := writeFreshOperationalFile(requestPath, request, 0o600); err != nil { + return "", "", err } - if err := syncDirectory(options.OutDir); err != nil { - return CommandResult{}, err + if err := syncDirectory(outDir); err != nil { + return "", "", err } complete = true - return CommandResult{ - CeremonyID: trusted.Definition.CeremonyID, - Summary: "exported exact canonical operational record bytes and digest for offline signing", - Outputs: map[string]string{ - "canonical": canonicalPath, - "signing_request": requestPath, - }, - }, nil + return canonicalPath, requestPath, nil } func executeOpsImportSignature(options OpsImportSignatureOptions) (CommandResult, error) { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 447de82..e28d512 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -62,6 +62,9 @@ func parseInvocation(args []string) (Invocation, error) { invocation.Command, invocation.Options = CommandInit, options return invocation, wrapCommandError(err, "init") case "inspect": + if len(rest) > 1 && !strings.HasPrefix(rest[1], "-") { + return parseInspectSubcommand(invocation, rest[1:]) + } options, err := parseInspect(rest[1:]) invocation.Command, invocation.Options = CommandInspect, options return invocation, wrapCommandError(err, "inspect") @@ -88,6 +91,126 @@ func parseInvocation(args []string) (Invocation, error) { } } +func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing inspect command", topic: []string{"inspect"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"inspect"}, args[1:]...)} + } + switch args[0] { + case "definition": + options, err := parseInspectDefinition(args[1:]) + invocation.Command, invocation.Options = CommandInspectDefinition, options + return invocation, wrapCommandError(err, "inspect", "definition") + case "chain": + options, err := parseInspectChain(args[1:]) + invocation.Command, invocation.Options = CommandInspectChain, options + return invocation, wrapCommandError(err, "inspect", "chain") + case "participant": + options, err := parseInspectParticipant(args[1:]) + invocation.Command, invocation.Options = CommandInspectParticipant, options + return invocation, wrapCommandError(err, "inspect", "participant") + case "enrollment": + options, err := parseInspectEnrollment(args[1:]) + invocation.Command, invocation.Options = CommandInspectEnrollment, options + return invocation, wrapCommandError(err, "inspect", "enrollment") + default: + return Invocation{}, &usageError{ + message: fmt.Sprintf("unknown inspect command %q", args[0]), + topic: []string{"inspect"}, + } + } +} + +func parseInspectParticipant(args []string) (InspectParticipantOptions, error) { + var options InspectParticipantOptions + fs := commandFlagSet("inspect participant") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "existing participant Ed25519 private key") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--participant-signing-key", options.ParticipantSigningKey), + ) +} + +func parseInspectEnrollment(args []string) (InspectEnrollmentOptions, error) { + var options InspectEnrollmentOptions + fs := commandFlagSet("inspect enrollment") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + fs.StringVar(&options.EnrollmentPath, "enrollment", "", "canonical operational enrollment record") + fs.StringVar(&options.EnrollmentSignaturePath, "enrollment-signature", "", "detached proof-of-possession signature") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--enrollment", options.EnrollmentPath), + pathValue("--enrollment-signature", options.EnrollmentSignaturePath), + ) +} + +func parseInspectDefinition(args []string) (InspectDefinitionOptions, error) { + var options InspectDefinitionOptions + fs := commandFlagSet("inspect definition") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + ) +} + +func parseInspectChain(args []string) (InspectChainOptions, error) { + var options InspectChainOptions + fs := commandFlagSet("inspect chain") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local transcript root") + fs.StringVar(&options.ChainPath, "chain", "", "explicit accepted chain JSON path") + fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached accepted chain signature path") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-root", options.TranscriptRoot), + pathValue("--chain", options.ChainPath), + pathValue("--chain-signature", options.ChainSignaturePath), + ) +} + func parseDecision(invocation Invocation, args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, &usageError{message: "missing decision command", topic: []string{"decision"}} @@ -216,6 +339,14 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)} } switch args[0] { + case "prepare-public-witness-receipt": + options, err := parseOpsPreparePublicWitnessReceipt(args[1:]) + invocation.Command, invocation.Options = CommandOpsPreparePublicWitnessReceipt, options + return invocation, wrapCommandError(err, "ops", "prepare-public-witness-receipt") + case "prepare-mirror-receipt": + options, err := parseOpsPrepareMirrorReceipt(args[1:]) + invocation.Command, invocation.Options = CommandOpsPrepareMirrorReceipt, options + return invocation, wrapCommandError(err, "ops", "prepare-mirror-receipt") case "export-signing": options, err := parseOpsExportSigning(args[1:]) invocation.Command, invocation.Options = CommandOpsExportSigning, options @@ -236,6 +367,64 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { } } +func parseOpsPreparePublicWitnessReceipt(args []string) (OpsPreparePublicWitnessReceiptOptions, error) { + var options OpsPreparePublicWitnessReceiptOptions + fs := commandFlagSet("ops prepare-public-witness-receipt") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local root containing the signed closure") + fs.StringVar(&options.ClosurePath, "closure", "", "exact coordinator-signed closure record") + fs.StringVar(&options.ClosureSignaturePath, "closure-signature", "", "detached coordinator signature for the closure") + fs.StringVar(&options.WitnessEnrollmentPath, "witness-enrollment", "", "canonical public-witness proof-of-possession enrollment") + fs.StringVar(&options.WitnessEnrollmentSignaturePath, "witness-enrollment-signature", "", "detached witness enrollment signature") + fs.StringVar(&options.PublicationLocation, "publication-location", "", "human-observed publication URI; only its SHA-256 is recorded") + fs.StringVar(&options.ObservedAt, "observed-at", "", "human-claimed observation time in RFC3339 UTC") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for canonical receipt and signing request") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-root", options.TranscriptRoot), + pathValue("--closure", options.ClosurePath), + pathValue("--closure-signature", options.ClosureSignaturePath), + pathValue("--witness-enrollment", options.WitnessEnrollmentPath), + pathValue("--witness-enrollment-signature", options.WitnessEnrollmentSignaturePath), + value("--publication-location", options.PublicationLocation), + value("--observed-at", options.ObservedAt), + pathValue("--out-dir", options.OutDir), + ) +} + +func parseOpsPrepareMirrorReceipt(args []string) (OpsPrepareMirrorReceiptOptions, error) { + var options OpsPrepareMirrorReceiptOptions + fs := commandFlagSet("ops prepare-mirror-receipt") + fs.StringVar(&options.DraftPath, "draft", "", "human-reviewable mirror receipt draft from relay") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local root containing the accepted chain prefix") + fs.StringVar(&options.ChainPath, "chain", "", "exact coordinator-signed accepted chain prefix") + fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached coordinator signature for the chain prefix") + fs.StringVar(&options.MirrorEnrollmentPath, "mirror-enrollment", "", "canonical mirror-operator proof-of-possession enrollment") + fs.StringVar(&options.MirrorEnrollmentSignaturePath, "mirror-enrollment-signature", "", "detached mirror enrollment signature") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for canonical receipt and signing request") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--draft", options.DraftPath), + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-root", options.TranscriptRoot), + pathValue("--chain", options.ChainPath), + pathValue("--chain-signature", options.ChainSignaturePath), + pathValue("--mirror-enrollment", options.MirrorEnrollmentPath), + pathValue("--mirror-enrollment-signature", options.MirrorEnrollmentSignaturePath), + pathValue("--out-dir", options.OutDir), + ) +} + func parseOpsExportSigning(args []string) (OpsExportSigningOptions, error) { var options OpsExportSigningOptions fs := commandFlagSet("ops export-signing") diff --git a/cmd/mpc-ceremony/public_witness_ops_test.go b/cmd/mpc-ceremony/public_witness_ops_test.go new file mode 100644 index 0000000..c7fd29d --- /dev/null +++ b/cmd/mpc-ceremony/public_witness_ops_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "proof-tool/internal/mpcceremony" +) + +func TestPreparePublicWitnessReceiptAuthenticatesClosureEnrollmentAndOutput(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + trustArgs := writeInspectionTrustFixture(t, root, definition, coordinatorKey) + trust := trustOptionsFromArgs(t, trustArgs) + + round := uint64(40_000_000) + roundTime, err := mpcceremony.QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + closeRecord, err := mpcceremony.NewCloseRecord(mpcceremony.CloseRecord{ + CeremonyID: definition.CeremonyID, + Phase: mpcceremony.Phase1, + PhaseID: "sha256:" + strings.Repeat("44", 32), + FinalIndex: 1, + FinalPayload: commandArtifact("phase1/final.bin", "final"), + ChainHeadID: "sha256:" + strings.Repeat("55", 32), + AcceptedParticipants: []string{definition.Roster[0].Identity.ID}, + BeaconProvider: definition.BeaconPolicy.Provider, + BeaconNetwork: definition.BeaconPolicy.Network, + BeaconRound: round, + BeaconNotBefore: roundTime.Format(time.RFC3339), + ClosedAt: roundTime.Add(-25 * time.Hour).Format(time.RFC3339), + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + }) + if err != nil { + t.Fatal(err) + } + closeBytes, closeSignature, err := mpcceremony.SignRecord( + closeRecord, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + closurePath := filepath.Join(root, "phase1", "closure", "record.json") + closureSignaturePath := filepath.Join(root, "phase1", "closure", "record.sig") + if err := os.MkdirAll(filepath.Dir(closurePath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, closurePath, closeBytes, 0o600) + writeDecisionTestFile(t, closureSignaturePath, closeSignature, 0o600) + + witness, witnessBytes, witnessSignature, _ := commandSignedExternalEnrollment( + t, + definition, + mpcceremony.EnrollmentPublicWitness, + "public-witness-01", + 0x91, + ) + witnessPath := filepath.Join(root, "operational", "enrollments", "public-witness-01.json") + witnessSignaturePath := filepath.Join(root, "operational", "enrollments", "public-witness-01.sig") + if err := os.MkdirAll(filepath.Dir(witnessPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, witnessPath, witnessBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, witnessSignature, 0o600) + + location := "https://independent.example/phase1/closure.json" + options := OpsPreparePublicWitnessReceiptOptions{ + CeremonyPath: trust.CeremonyPath, + CeremonySignaturePath: trust.CeremonySignaturePath, + CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyFile, + TranscriptRoot: root, + ClosurePath: closurePath, + ClosureSignaturePath: closureSignaturePath, + WitnessEnrollmentPath: witnessPath, + WitnessEnrollmentSignaturePath: witnessSignaturePath, + PublicationLocation: location, + ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339), + OutDir: filepath.Join(root, "witness-signing"), + } + result, err := executeOpsPreparePublicWitnessReceipt(options) + if err != nil { + t.Fatal(err) + } + canonical, err := os.ReadFile(result.Outputs["canonical"]) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(canonical, []byte(location)) { + t.Fatal("canonical receipt contains cleartext publication location") + } + var receipt mpcceremony.PublicWitnessReceipt + if err := mpcceremony.UnmarshalCanonical(canonical, &receipt); err != nil { + t.Fatal(err) + } + if receipt.Witness != witness.Identity || receipt.Closure.Name != "phase1/closure/record.json" || + receipt.ObservedAt != options.ObservedAt { + t.Fatalf("receipt = %#v", receipt) + } + requestBytes, err := os.ReadFile(result.Outputs["signing_request"]) + if err != nil { + t.Fatal(err) + } + var request mpcceremony.OperationalSigningRequest + if err := mpcceremony.UnmarshalCanonical(requestBytes, &request); err != nil { + t.Fatal(err) + } + if request.RecordType != mpcceremony.RecordPublicWitness { + t.Fatalf("signing request record type = %q", request.RecordType) + } + + canonicalBefore := append([]byte(nil), canonical...) + if _, err := executeOpsPreparePublicWitnessReceipt(options); err == nil { + t.Fatal("existing output directory unexpectedly replaced") + } + canonicalAfter, err := os.ReadFile(result.Outputs["canonical"]) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(canonicalBefore, canonicalAfter) { + t.Fatal("failed retry changed existing canonical receipt") + } + + mirror, mirrorBytes, mirrorSignature, _ := commandSignedExternalEnrollment( + t, + definition, + mpcceremony.EnrollmentMirrorOperator, + "mirror-operator-01", + 0xa1, + ) + _ = mirror + writeDecisionTestFile(t, witnessPath, mirrorBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, mirrorSignature, 0o600) + wrongRole := options + wrongRole.OutDir = filepath.Join(root, "wrong-role") + if _, err := executeOpsPreparePublicWitnessReceipt(wrongRole); err == nil { + t.Fatal("mirror enrollment unexpectedly prepared a witness receipt") + } + if _, err := os.Stat(wrongRole.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("wrong-role preparation left output directory: %v", err) + } + + overlap := witness + overlap.Identity = definition.Coordinator + overlapBytes, overlapSignature, err := mpcceremony.SignRecord( + overlap, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, witnessPath, overlapBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, overlapSignature, 0o600) + overlapOptions := options + overlapOptions.OutDir = filepath.Join(root, "overlap") + if _, err := executeOpsPreparePublicWitnessReceipt(overlapOptions); err == nil { + t.Fatal("ceremony actor unexpectedly accepted as public witness") + } +} + +func TestPreparePublicWitnessReceiptRejectsAlteredAndWronglySignedClosure(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + trust := trustOptionsFromArgs(t, writeInspectionTrustFixture(t, root, definition, coordinatorKey)) + round := uint64(40_000_000) + roundTime, _ := mpcceremony.QuicknetRoundTime(round) + closeRecord, err := mpcceremony.NewCloseRecord(mpcceremony.CloseRecord{ + CeremonyID: definition.CeremonyID, + Phase: mpcceremony.Phase1, + PhaseID: "sha256:" + strings.Repeat("44", 32), + FinalIndex: 1, + FinalPayload: commandArtifact("phase1/final.bin", "final"), + ChainHeadID: "sha256:" + strings.Repeat("55", 32), + AcceptedParticipants: []string{definition.Roster[0].Identity.ID}, + BeaconProvider: definition.BeaconPolicy.Provider, + BeaconNetwork: definition.BeaconPolicy.Network, + BeaconRound: round, + BeaconNotBefore: roundTime.Format(time.RFC3339), + ClosedAt: roundTime.Add(-25 * time.Hour).Format(time.RFC3339), + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + }) + if err != nil { + t.Fatal(err) + } + closeBytes, closeSignature, err := mpcceremony.SignRecord(closeRecord, definition.Coordinator.KeyID, coordinatorKey) + if err != nil { + t.Fatal(err) + } + closurePath := filepath.Join(root, "phase1", "closure", "record.json") + closureSignaturePath := filepath.Join(root, "phase1", "closure", "record.sig") + if err := os.MkdirAll(filepath.Dir(closurePath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, closurePath, closeBytes, 0o600) + writeDecisionTestFile(t, closureSignaturePath, closeSignature, 0o600) + _, witnessBytes, witnessSignature, witnessKey := commandSignedExternalEnrollment( + t, definition, mpcceremony.EnrollmentPublicWitness, "public-witness-01", 0x91, + ) + witnessPath := filepath.Join(root, "witness.json") + witnessSignaturePath := filepath.Join(root, "witness.sig") + writeDecisionTestFile(t, witnessPath, witnessBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, witnessSignature, 0o600) + options := OpsPreparePublicWitnessReceiptOptions{ + CeremonyPath: trust.CeremonyPath, + CeremonySignaturePath: trust.CeremonySignaturePath, + CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyFile, + TranscriptRoot: root, + ClosurePath: closurePath, + ClosureSignaturePath: closureSignaturePath, + WitnessEnrollmentPath: witnessPath, + WitnessEnrollmentSignaturePath: witnessSignaturePath, + PublicationLocation: "https://independent.example/closure", + ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339), + OutDir: filepath.Join(root, "altered-output"), + } + + writeDecisionTestFile(t, closurePath, append(closeBytes, '\n'), 0o600) + if _, err := executeOpsPreparePublicWitnessReceipt(options); err == nil { + t.Fatal("altered closure unexpectedly accepted") + } + writeDecisionTestFile(t, closurePath, closeBytes, 0o600) + _, wrongSignature, err := mpcceremony.SignRecord(closeRecord, "public-witness-01-key", witnessKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, closureSignaturePath, wrongSignature, 0o600) + if _, err := executeOpsPreparePublicWitnessReceipt(options); err == nil { + t.Fatal("closure signed by witness key unexpectedly accepted") + } + if _, err := os.Stat(options.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected closures left output directory: %v", err) + } +} + +func TestWriteOperationalSigningExportCleansPartialPublication(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "partial") + if _, _, err := writeOperationalSigningExport(outDir, []byte("canonical"), nil); err == nil { + t.Fatal("empty signing request unexpectedly exported") + } + if _, err := os.Stat(outDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("partial signing export was not removed: %v", err) + } +} + +func trustOptionsFromArgs(t *testing.T, args []string) InspectDefinitionOptions { + t.Helper() + invocation, err := parseInvocation(append([]string{"inspect", "definition"}, args...)) + if err != nil { + t.Fatal(err) + } + return invocation.Options.(InspectDefinitionOptions) +} + +func commandArtifact(name, contents string) mpcceremony.ArtifactRef { + return mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.NewDigest([]byte(contents))} +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 29a636e..6cbb7f2 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -6,6 +6,8 @@ package main import ( "context" "errors" + + "proof-tool/internal/mpcceremony" ) const commandResultSchema = "proof-tool-mpc-command-result-v1" @@ -13,31 +15,37 @@ const commandResultSchema = "proof-tool-mpc-command-result-v1" type Command string const ( - CommandInit Command = "init" - CommandInspect Command = "inspect" - CommandPhase1Contribute Command = "phase1 contribute" - CommandPhase1Erasure Command = "phase1 attest-erasure" - CommandPhase1Verify Command = "phase1 verify" - CommandPhase1Close Command = "phase1 close" - CommandPhase1Beacon Command = "phase1 beacon" - CommandPhase1Seal Command = "phase1 seal" - CommandPhase2Init Command = "phase2 init" - CommandPhase2Contribute Command = "phase2 contribute" - CommandPhase2Erasure Command = "phase2 attest-erasure" - CommandPhase2Verify Command = "phase2 verify" - CommandPhase2Close Command = "phase2 close" - CommandPhase2Beacon Command = "phase2 beacon" - CommandFinalizePrepare Command = "finalize prepare" - CommandFinalizeComplete Command = "finalize complete" - CommandAudit Command = "audit" - CommandReleaseSign Command = "release sign" - CommandReleaseVerify Command = "release verify" - CommandOpsExportSigning Command = "ops export-signing" - CommandOpsImportSig Command = "ops import-signature" - CommandOpsVerify Command = "ops verify" - CommandDecisionPrepare Command = "decision prepare" - CommandDecisionSign Command = "decision sign" - CommandDecisionVerify Command = "decision verify" + CommandInit Command = "init" + CommandInspect Command = "inspect" + CommandPhase1Contribute Command = "phase1 contribute" + CommandPhase1Erasure Command = "phase1 attest-erasure" + CommandPhase1Verify Command = "phase1 verify" + CommandPhase1Close Command = "phase1 close" + CommandPhase1Beacon Command = "phase1 beacon" + CommandPhase1Seal Command = "phase1 seal" + CommandPhase2Init Command = "phase2 init" + CommandPhase2Contribute Command = "phase2 contribute" + CommandPhase2Erasure Command = "phase2 attest-erasure" + CommandPhase2Verify Command = "phase2 verify" + CommandPhase2Close Command = "phase2 close" + CommandPhase2Beacon Command = "phase2 beacon" + CommandFinalizePrepare Command = "finalize prepare" + CommandFinalizeComplete Command = "finalize complete" + CommandAudit Command = "audit" + CommandReleaseSign Command = "release sign" + CommandReleaseVerify Command = "release verify" + CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" + CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" + CommandOpsExportSigning Command = "ops export-signing" + CommandOpsImportSig Command = "ops import-signature" + CommandOpsVerify Command = "ops verify" + CommandDecisionPrepare Command = "decision prepare" + CommandDecisionSign Command = "decision sign" + CommandDecisionVerify Command = "decision verify" + CommandInspectDefinition Command = "inspect definition" + CommandInspectChain Command = "inspect chain" + CommandInspectParticipant Command = "inspect participant" + CommandInspectEnrollment Command = "inspect enrollment" ) type GlobalOptions struct { @@ -221,6 +229,33 @@ type OpsExportSigningOptions struct { OutDir string } +type OpsPrepareMirrorReceiptOptions struct { + DraftPath string + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + TranscriptRoot string + ChainPath string + ChainSignaturePath string + MirrorEnrollmentPath string + MirrorEnrollmentSignaturePath string + OutDir string +} + +type OpsPreparePublicWitnessReceiptOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + TranscriptRoot string + ClosurePath string + ClosureSignaturePath string + WitnessEnrollmentPath string + WitnessEnrollmentSignaturePath string + PublicationLocation string + ObservedAt string + OutDir string +} + type OpsImportSignatureOptions struct { RecordType string CanonicalPath string @@ -244,6 +279,75 @@ type OpsVerifyOptions struct { EvidenceRoot string } +type InspectDefinitionOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string +} + +type InspectChainOptions struct { + InspectDefinitionOptions + TranscriptRoot string + ChainPath string + ChainSignaturePath string +} + +type InspectParticipantOptions struct { + InspectDefinitionOptions + ParticipantSigningKey string +} + +type InspectEnrollmentOptions struct { + InspectDefinitionOptions + EnrollmentPath string + EnrollmentSignaturePath string +} + +type DefinitionInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + Phase1Participants []string `json:"phase1_participants"` + Phase2Participants []string `json:"phase2_participants"` + R1CS mpcceremony.ArtifactRef `json:"r1cs"` +} + +type ChainRecordInspection struct { + Index uint8 `json:"index"` + RecordID string `json:"record_id"` + ParticipantID string `json:"participant_id"` + Artifacts []mpcceremony.ArtifactRef `json:"artifacts"` +} + +type ChainInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Phase mpcceremony.Phase `json:"phase"` + AcceptedCount int `json:"accepted_count"` + Artifacts []mpcceremony.ArtifactRef `json:"artifacts"` + Records []ChainRecordInspection `json:"records"` +} + +type ParticipantInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + ParticipantID string `json:"participant_id"` + KeyID string `json:"key_id"` + PublicKeyFingerprint string `json:"public_key_fingerprint"` + Phase1Position *uint8 `json:"phase1_position"` + Phase2Position *uint8 `json:"phase2_position"` +} + +type EnrollmentInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Identity mpcceremony.Identity `json:"identity"` + Role mpcceremony.EnrollmentRole `json:"role"` + RoleIndex uint16 `json:"role_index"` + EnrolledAt string `json:"enrolled_at"` + IndependenceDisclosure mpcceremony.ArtifactRef `json:"independence_disclosure"` +} + type DecisionSignOptions struct { CeremonyPath string CeremonySignaturePath string @@ -292,23 +396,27 @@ type ReplayOptions struct { } type CommandResult struct { - Schema string `json:"schema"` - OK bool `json:"ok"` - Command Command `json:"command"` - CeremonyID string `json:"ceremony_id,omitempty"` - Phase string `json:"phase,omitempty"` - Sequence int `json:"sequence,omitempty"` - ClosedAt string `json:"closed_at,omitempty"` - Decision string `json:"decision,omitempty"` - DecisionID string `json:"decision_id,omitempty"` - ReleaseID string `json:"release_id,omitempty"` - CandidateID string `json:"candidate_id,omitempty"` - SourceCommit string `json:"source_commit,omitempty"` - SourceSignedTag string `json:"source_signed_tag,omitempty"` - SourceTagSignerFingerprint string `json:"source_tag_signer_fingerprint,omitempty"` - SourceTagObjectSHA256 string `json:"source_tag_object_sha256,omitempty"` - Outputs map[string]string `json:"outputs,omitempty"` - Summary string `json:"summary,omitempty"` + Schema string `json:"schema"` + OK bool `json:"ok"` + Command Command `json:"command"` + CeremonyID string `json:"ceremony_id,omitempty"` + Phase string `json:"phase,omitempty"` + Sequence int `json:"sequence,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + Decision string `json:"decision,omitempty"` + DecisionID string `json:"decision_id,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + CandidateID string `json:"candidate_id,omitempty"` + SourceCommit string `json:"source_commit,omitempty"` + SourceSignedTag string `json:"source_signed_tag,omitempty"` + SourceTagSignerFingerprint string `json:"source_tag_signer_fingerprint,omitempty"` + SourceTagObjectSHA256 string `json:"source_tag_object_sha256,omitempty"` + Outputs map[string]string `json:"outputs,omitempty"` + Summary string `json:"summary,omitempty"` + DefinitionInspection *DefinitionInspection `json:"definition_inspection,omitempty"` + ChainInspection *ChainInspection `json:"chain_inspection,omitempty"` + ParticipantInspection *ParticipantInspection `json:"participant_inspection,omitempty"` + EnrollmentInspection *EnrollmentInspection `json:"enrollment_inspection,omitempty"` } type Executor interface { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index fd5ec25..d3400e0 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -49,6 +49,12 @@ Commands: decision prepare Derive the canonical production GO/NO-GO record decision sign Sign the canonical production GO/NO-GO record decision verify Verify decision evidence and role threshold + inspect definition Authenticate and describe a ceremony definition + inspect chain Authenticate and describe an accepted chain + inspect participant Match an existing key to the participant roster + inspect enrollment Authenticate an operational enrollment + ops prepare-public-witness-receipt Prepare witnessed closure bytes + ops prepare-mirror-receipt Authenticate a relay draft for offline signing ops export-signing Export canonical operational bytes for offline signing ops import-signature Import and verify a raw offline Ed25519 signature ops verify Verify a signed operational record fail-closed @@ -99,7 +105,49 @@ second path list. ` var commandHelp = map[string]string{ - "inspect": inspectHelp, + "inspect": inspectHelp + ` +Authenticated record projections are also available as subcommands: + mpc-ceremony inspect [flags] + +These subcommands are read-only and machine-readable. They perform no network +access, replay, signing, or writes. +`, + "inspect definition": `Usage: + mpc-ceremony --format json inspect definition --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY + +Authenticates the exact canonical ceremony definition against the out-of-band +coordinator public key and reports its identity, mode, schedules, and circuit. +`, + "inspect chain": `Usage: + mpc-ceremony --format json inspect chain --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --transcript-root DIR --chain FILE --chain-signature FILE + +Authenticates the definition and accepted chain, validates the chain against +the frozen ceremony, and reports its records and digest-pinned artifacts. It +does not replay contribution payloads. +`, + "inspect participant": `Usage: + mpc-ceremony --format json inspect participant --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --participant-signing-key KEY + +Loads the existing Ed25519 private key with the hardened contribution-key +rules, derives only its public key, and matches it to exactly one identity in +the authenticated participant roster. Reports one-based phase schedule +positions, using null when the participant is absent. It performs no signing or +writes and never emits private-key bytes. +`, + "inspect enrollment": `Usage: + mpc-ceremony --format json inspect enrollment --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --enrollment FILE --enrollment-signature FILE + +Authenticates the exact canonical operational enrollment and its detached +proof-of-possession signature, then reports an immutable public projection of +the identity, role, role index, timestamp, and independence disclosure. +`, "init": `Usage: mpc-ceremony init --key-version ownership-destination-v2 \ --participants ROSTER.json --policy POLICY.json \ @@ -365,11 +413,40 @@ coherence, and fail-closes GO unless all gates PASS and all four roles signed. Evidence URIs are content bindings only; the command performs no network fetch. `, "ops": `Usage: - mpc-ceremony ops [flags] + mpc-ceremony ops [flags] Operational records cover proof-of-possession enrollment, transfers and receipts, immutable mirrors, pre-beacon public witnesses, multi-operator relay evidence, governance events, and the release-bound operational evidence bundle. +`, + "ops prepare-public-witness-receipt": `Usage: + mpc-ceremony ops prepare-public-witness-receipt \ + --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root DIR \ + --closure FILE --closure-signature FILE \ + --witness-enrollment FILE --witness-enrollment-signature FILE \ + --publication-location URI --observed-at RFC3339_UTC \ + --out-dir FRESH_DIR + +Authenticates the ceremony, coordinator-signed closure, and public-witness +proof-of-possession enrollment. It validates the human-claimed observation +against the signed closure and beacon schedule, hashes the publication location, +and exports canonical.json plus signing-request.json for offline review and +signing. The program validates coherence; it does not claim to have observed +publication itself and never reads the witness private key. +`, + "ops prepare-mirror-receipt": `Usage: + mpc-ceremony ops prepare-mirror-receipt --draft FILE \ + --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root DIR \ + --chain FILE --chain-signature FILE \ + --mirror-enrollment FILE --mirror-enrollment-signature FILE \ + --out-dir FRESH_DIR + +Authenticates the exact accepted chain prefix and the mirror operator's signed +proof-of-possession enrollment, recomputes every receipt file reference, and +requires the relay draft to match. It then exports canonical.json and +signing-request.json without reading a private signing key. `, "ops export-signing": `Usage: mpc-ceremony ops export-signing --record-type TYPE --record FILE \ diff --git a/internal/mpcceremony/inspection.go b/internal/mpcceremony/inspection.go new file mode 100644 index 0000000..d936b52 --- /dev/null +++ b/internal/mpcceremony/inspection.go @@ -0,0 +1,142 @@ +package mpcceremony + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "proof-tool/internal/keybundle" +) + +// ParticipantSigningKeyMatch is the immutable public result of matching an +// existing participant signing key to the authenticated ceremony roster. +type ParticipantSigningKeyMatch struct { + ParticipantID string + KeyID string + PublicKeyFingerprint string + Phase1Position *uint8 + Phase2Position *uint8 +} + +// InspectParticipantSigningKey loads an existing Ed25519 private key with the +// same hardened rules used by contribution commands, derives only its public +// key, and matches that public key to exactly one roster participant. It never +// signs or writes anything. +func InspectParticipantSigningKey( + definition CeremonyDefinition, + privateKeyPath string, +) (ParticipantSigningKeyMatch, error) { + if err := definition.Validate(); err != nil { + return ParticipantSigningKeyMatch{}, err + } + privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(privateKeyPath) + if err != nil { + return ParticipantSigningKeyMatch{}, err + } + defer clear(privateKey) + + matches := make([]Identity, 0, 1) + for _, participant := range definition.Roster { + expected, err := identityPublicKey(participant.Identity) + if err != nil { + return ParticipantSigningKeyMatch{}, err + } + if bytes.Equal(publicKey, expected) { + matches = append(matches, participant.Identity) + } + } + if len(matches) > 1 { + return ParticipantSigningKeyMatch{}, errors.New("participant signing key matches more than one roster identity") + } + if len(matches) == 0 { + for _, identity := range nonParticipantCeremonyIdentities(definition) { + expected, err := identityPublicKey(identity) + if err != nil { + return ParticipantSigningKeyMatch{}, err + } + if bytes.Equal(publicKey, expected) { + return ParticipantSigningKeyMatch{}, fmt.Errorf( + "signing key matches non-participant ceremony identity %q", + identity.ID, + ) + } + } + return ParticipantSigningKeyMatch{}, errors.New("signing key does not match any participant in the authenticated roster") + } + + identity := matches[0] + return ParticipantSigningKeyMatch{ + ParticipantID: identity.ID, + KeyID: identity.KeyID, + PublicKeyFingerprint: identity.PublicKeyFingerprint, + Phase1Position: participantSchedulePosition(definition.Phase1Policy.Participants, identity.ID), + Phase2Position: participantSchedulePosition(definition.Phase2Policy.Participants, identity.ID), + }, nil +} + +func participantSchedulePosition(schedule []string, participantID string) *uint8 { + for index, id := range schedule { + if id == participantID { + position := uint8(index + 1) + return &position + } + } + return nil +} + +func nonParticipantCeremonyIdentities(definition CeremonyDefinition) []Identity { + identities := make([]Identity, 0, 2+len(definition.Auditors)) + identities = append(identities, definition.Coordinator, definition.ReleaseSigner) + identities = append(identities, definition.Auditors...) + return identities +} + +// LoadSignedCloseExact authenticates an exact coordinator-signed closure, +// validates its definition-level binding, and returns the safe transcript name +// derived from the same root used to constrain both input paths. +func LoadSignedCloseExact( + trusted *TrustedCeremony, + transcriptRoot, closePath, signaturePath string, +) (AuthenticatedCloseEvidence, string, error) { + if err := validateTrustedCeremony(trusted); err != nil { + return AuthenticatedCloseEvidence{}, "", err + } + if strings.TrimSpace(transcriptRoot) == "" || strings.TrimSpace(closePath) == "" || + strings.TrimSpace(signaturePath) == "" { + return AuthenticatedCloseEvidence{}, "", errors.New("transcript root, closure, and closure signature paths are required") + } + closeName, err := logicalPathWithin(transcriptRoot, closePath) + if err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("closure path: %w", err) + } + if _, err := logicalPathWithin(transcriptRoot, signaturePath); err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("closure signature path: %w", err) + } + closeBytes, err := readRegularBounded(closePath, maxSignedRecordBytes) + if err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("load signed closure: %w", err) + } + signatureBytes, err := readRegularBounded(signaturePath, maxSignedRecordBytes) + if err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("load signed closure: %w", err) + } + var closeRecord CloseRecord + if err := VerifySignedRecord( + closeBytes, + signatureBytes, + &closeRecord, + trusted.Definition.Coordinator.KeyID, + trusted.CoordinatorPublicKey, + ); err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("load signed closure: %w", err) + } + if err := validatePublicWitnessCloseBinding(trusted.Definition, closeRecord); err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("closure against definition: %w", err) + } + return AuthenticatedCloseEvidence{ + Record: closeRecord, + RecordBytes: closeBytes, + SignatureBytes: signatureBytes, + }, closeName, nil +} diff --git a/internal/mpcceremony/inspection_test.go b/internal/mpcceremony/inspection_test.go new file mode 100644 index 0000000..3dd1bef --- /dev/null +++ b/internal/mpcceremony/inspection_test.go @@ -0,0 +1,241 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInspectParticipantSigningKeyMatchesRosterAndSchedule(t *testing.T) { + definition := adversarialDefinition(t) + keyPath := writeInspectionPrivateKey(t, adversarialPrivateKey(0x12)) + match, err := InspectParticipantSigningKey(definition, keyPath) + if err != nil { + t.Fatal(err) + } + identity := definition.Roster[1].Identity + if match.ParticipantID != identity.ID || match.KeyID != identity.KeyID || + match.PublicKeyFingerprint != identity.PublicKeyFingerprint { + t.Fatalf("participant match = %#v", match) + } + if match.Phase1Position == nil || *match.Phase1Position != 2 || + match.Phase2Position == nil || *match.Phase2Position != 2 { + t.Fatalf("participant positions = phase1 %v, phase2 %v", match.Phase1Position, match.Phase2Position) + } + if position := participantSchedulePosition([]string{"participant-01"}, identity.ID); position != nil { + t.Fatalf("absent participant position = %d, want nil", *position) + } +} + +func TestInspectParticipantSigningKeyRejectsUnknownMalformedAndNonParticipants(t *testing.T) { + definition := adversarialDefinition(t) + tests := []struct { + name string + data []byte + }{ + {name: "unknown", data: privateKeyHex(adversarialPrivateKey(0xee))}, + {name: "malformed", data: []byte("not-hex\n")}, + {name: "coordinator", data: privateKeyHex(adversarialPrivateKey(0x01))}, + {name: "release signer", data: privateKeyHex(adversarialPrivateKey(0x02))}, + {name: "auditor", data: privateKeyHex(adversarialPrivateKey(0x03))}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "identity.private.hex") + if err := os.WriteFile(path, test.data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := InspectParticipantSigningKey(definition, path); err == nil { + t.Fatal("non-participant or malformed key unexpectedly accepted") + } + }) + } +} + +func TestVerifyEnrollmentProofOfPossessionSupportsWitnessAndMirror(t *testing.T) { + definition := adversarialDefinition(t) + definitionBytes, err := MarshalCanonical(definition) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + role EnrollmentRole + id string + fill byte + }{ + {role: EnrollmentPublicWitness, id: "public-witness-01", fill: 0x91}, + {role: EnrollmentMirrorOperator, id: "mirror-operator-01", fill: 0xa1}, + } { + t.Run(string(test.role), func(t *testing.T) { + record, recordBytes, signatureBytes := signedExternalEnrollment( + t, definition, definitionBytes, test.role, test.id, test.fill, + ) + verified, err := VerifyEnrollmentProofOfPossession( + definition, definitionBytes, recordBytes, signatureBytes, + ) + if err != nil { + t.Fatal(err) + } + if verified.Identity != record.Identity || verified.Role != test.role { + t.Fatalf("verified enrollment = %#v", verified) + } + + altered := record + altered.EnrolledAt = "2026-07-23T12:00:02Z" + alteredBytes, err := MarshalCanonical(altered) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyEnrollmentProofOfPossession( + definition, definitionBytes, alteredBytes, signatureBytes, + ); err == nil { + t.Fatal("altered enrollment unexpectedly accepted") + } + + var signature DetachedSignature + if err := UnmarshalCanonical(signatureBytes, &signature); err != nil { + t.Fatal(err) + } + signature.SignatureHex = strings.Repeat("00", ed25519.SignatureSize) + alteredSignature, err := MarshalCanonical(signature) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyEnrollmentProofOfPossession( + definition, definitionBytes, recordBytes, alteredSignature, + ); err == nil { + t.Fatal("altered enrollment signature unexpectedly accepted") + } + }) + } +} + +func TestPreparePublicWitnessReceiptEnforcesRoleIdentityAndObservationTiming(t *testing.T) { + definition := adversarialDefinition(t) + definitionBytes, _ := MarshalCanonical(definition) + witness, _, _ := signedExternalEnrollment( + t, definition, definitionBytes, EnrollmentPublicWitness, "public-witness-01", 0x91, + ) + mirror, _, _ := signedExternalEnrollment( + t, definition, definitionBytes, EnrollmentMirrorOperator, "mirror-operator-01", 0xa1, + ) + round := uint64(40_000_000) + roundTime, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + closeRecord := operationalClose(t, definition, Phase1, round, roundTime.Add(-25*time.Hour)) + closeBytes, err := MarshalCanonical(closeRecord) + if err != nil { + t.Fatal(err) + } + location := "https://independent.example/phase1/closure.json" + observedAt := roundTime.Add(-24 * time.Hour).Format(time.RFC3339) + receipt, canonical, err := PreparePublicWitnessReceipt( + definition, + closeRecord, + closeBytes, + witness, + "phase1/closure/record.json", + location, + observedAt, + ) + if err != nil { + t.Fatal(err) + } + if receipt.Witness != witness.Identity || receipt.PublicationLocationSHA != taggedSHA256([]byte(location)) { + t.Fatalf("prepared receipt = %#v", receipt) + } + if bytes.Contains(canonical, []byte(location)) { + t.Fatal("canonical receipt exposed cleartext publication location") + } + + if _, _, err := PreparePublicWitnessReceipt( + definition, closeRecord, closeBytes, mirror, "phase1/closure/record.json", location, observedAt, + ); err == nil { + t.Fatal("mirror enrollment unexpectedly accepted for public-witness receipt") + } + + overlap := witness + overlap.Identity = definition.Coordinator + if _, _, err := PreparePublicWitnessReceipt( + definition, closeRecord, closeBytes, overlap, "phase1/closure/record.json", location, observedAt, + ); err == nil { + t.Fatal("witness enrollment overlapping the coordinator unexpectedly accepted") + } + + for _, test := range []struct { + name string + observedAt time.Time + }{ + {name: "before closure", observedAt: roundTime.Add(-26 * time.Hour)}, + {name: "at beacon round", observedAt: roundTime}, + {name: "after beacon round", observedAt: roundTime.Add(time.Second)}, + {name: "below minimum lead", observedAt: roundTime.Add(-24*time.Hour + time.Second)}, + } { + t.Run(test.name, func(t *testing.T) { + if _, _, err := PreparePublicWitnessReceipt( + definition, + closeRecord, + closeBytes, + witness, + "phase1/closure/record.json", + location, + test.observedAt.Format(time.RFC3339), + ); err == nil { + t.Fatal("invalid observation time unexpectedly accepted") + } + }) + } +} + +func writeInspectionPrivateKey(t *testing.T, key ed25519.PrivateKey) string { + t.Helper() + path := filepath.Join(t.TempDir(), "participant.private.hex") + if err := os.WriteFile(path, privateKeyHex(key), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func privateKeyHex(key ed25519.PrivateKey) []byte { + return []byte(hex.EncodeToString(key.Seed()) + "\n") +} + +func signedExternalEnrollment( + t *testing.T, + definition CeremonyDefinition, + definitionBytes []byte, + role EnrollmentRole, + id string, + fill byte, +) (EnrollmentRecord, []byte, []byte) { + t.Helper() + privateKey := adversarialPrivateKey(fill) + identity, err := NewIdentity(id, "Test "+id, id+"-key", privateKey.Public().(ed25519.PublicKey)) + if err != nil { + t.Fatal(err) + } + record, err := NewEnrollmentRecord( + definition, + definitionBytes, + identity, + role, + 1, + ArtifactRef{Name: "disclosures/" + id + ".json", Digest: NewDigest([]byte("independent " + id))}, + "2026-07-23T12:00:01Z", + ) + if err != nil { + t.Fatal(err) + } + recordBytes, signatureBytes, err := SignRecord(record, identity.KeyID, privateKey) + if err != nil { + t.Fatal(err) + } + return record, recordBytes, signatureBytes +} diff --git a/internal/mpcceremony/mirror_receipt_prepare_test.go b/internal/mpcceremony/mirror_receipt_prepare_test.go new file mode 100644 index 0000000..34f7f52 --- /dev/null +++ b/internal/mpcceremony/mirror_receipt_prepare_test.go @@ -0,0 +1,139 @@ +package mpcceremony + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + "time" +) + +func TestPrepareImmutableMirrorReceiptAuthenticatesDraftChainAndEnrollment(t *testing.T) { + fixture := newOperationalBundleFixture(t) + definitionBytes, err := MarshalCanonical(fixture.definition) + if err != nil { + t.Fatal(err) + } + head := fixture.bundle.Phase1.AcceptedHeads[0] + chainBytes, err := verifyArtifactBytes(fixture.root, head.AcceptedChainPrefix.Record, maxSignedRecordBytes) + if err != nil { + t.Fatal(err) + } + var chain Chain + if err := UnmarshalCanonical(chainBytes, &chain); err != nil { + t.Fatal(err) + } + + var enrollment EnrollmentRecord + var enrollmentBytes, enrollmentSignatureBytes []byte + for _, pair := range fixture.bundle.Enrollments { + recordBytes, err := verifyArtifactBytes(fixture.root, pair.Record, maxSignedRecordBytes) + if err != nil { + t.Fatal(err) + } + var candidate EnrollmentRecord + if err := UnmarshalCanonical(recordBytes, &candidate); err != nil { + t.Fatal(err) + } + if candidate.Role != EnrollmentMirrorOperator { + continue + } + signatureBytes, err := verifyArtifactBytes(fixture.root, pair.Signature, maxSignedRecordBytes) + if err != nil { + t.Fatal(err) + } + enrollment, err = VerifyEnrollmentProofOfPossession( + fixture.definition, definitionBytes, recordBytes, signatureBytes, + ) + if err != nil { + t.Fatal(err) + } + enrollmentBytes, enrollmentSignatureBytes = recordBytes, signatureBytes + break + } + if enrollment.Role != EnrollmentMirrorOperator { + t.Fatal("fixture has no mirror enrollment") + } + + files, err := MirrorReceiptFiles(chain.Records[0], head.AcceptedChainPrefix) + if err != nil { + t.Fatal(err) + } + acceptedAt, _ := time.Parse(time.RFC3339Nano, chain.Records[0].AcceptedAt) + draft := MirrorReceiptDraft{ + CeremonyID: fixture.definition.CeremonyID, + Phase: Phase1, + Index: 1, + AcceptedHeadID: chain.Records[0].RecordID, + Files: append([]ArtifactRef(nil), files...), + StorageLocationSHA256: taggedSHA256([]byte("immutable://mirror-operator-01")), + StoredAt: acceptedAt.Add(time.Minute).Format(time.RFC3339), + } + // Relay intentionally has only a SHA-256 transport digest for the two + // coordinator-signed prefix files. Preparation recomputes their full + // ceremony digests from the exact authenticated bytes. + for index := range draft.Files { + if draft.Files[index].Name == head.AcceptedChainPrefix.Record.Name || + draft.Files[index].Name == head.AcceptedChainPrefix.Signature.Name { + draft.Files[index].Digest.Blake2b256 = "" + } + } + prettyDraft, err := json.MarshalIndent(draft, "", " ") + if err != nil { + t.Fatal(err) + } + parsed, err := ParseMirrorReceiptDraft(prettyDraft) + if err != nil { + t.Fatal(err) + } + receipt, canonical, err := PrepareImmutableMirrorReceipt( + fixture.definition, chain, head.AcceptedChainPrefix, parsed, enrollment, + ) + if err != nil { + t.Fatal(err) + } + if receipt.Mirror != enrollment.Identity || !slices.Equal(receipt.Files, files) { + t.Fatal("prepared receipt did not derive mirror identity and exact files") + } + for _, file := range receipt.Files { + if file.Digest.Blake2b256 == "" { + t.Fatalf("canonical receipt retained missing BLAKE2b digest for %q", file.Name) + } + } + var decoded ImmutableMirrorReceipt + if err := UnmarshalCanonical(canonical, &decoded); err != nil { + t.Fatalf("prepared bytes are not canonical: %v", err) + } + if !reflect.DeepEqual(decoded, receipt) { + t.Fatal("canonical receipt differs from prepared receipt") + } + + tampered := parsed + tampered.Files = append([]ArtifactRef(nil), parsed.Files...) + tampered.Files[0].Digest = NewDigest([]byte("substituted mirror bytes")) + if _, _, err := PrepareImmutableMirrorReceipt( + fixture.definition, chain, head.AcceptedChainPrefix, tampered, enrollment, + ); err == nil { + t.Fatal("draft with substituted artifact unexpectedly accepted") + } + + if _, err := VerifyEnrollmentProofOfPossession( + fixture.definition, + definitionBytes, + enrollmentBytes, + append([]byte(nil), enrollmentSignatureBytes[:len(enrollmentSignatureBytes)-1]...), + ); err == nil { + t.Fatal("truncated mirror enrollment signature unexpectedly accepted") + } +} + +func TestParseMirrorReceiptDraftRejectsUnknownAndDuplicateFields(t *testing.T) { + base := `{"ceremony_id":"sha256:` + strings.Repeat("11", 32) + `","phase":"phase1","index":1,"accepted_head_id":"sha256:` + strings.Repeat("22", 32) + `","files":[{"name":"phase1/file","digest":{"sha256":"` + strings.Repeat("33", 32) + `","blake2b256":"` + strings.Repeat("44", 32) + `","size":1}}],"storage_location_sha256":"sha256:` + strings.Repeat("55", 32) + `","stored_at":"2026-08-18T00:00:00Z"}` + if _, err := ParseMirrorReceiptDraft([]byte(strings.Replace(base, `"phase":"phase1"`, `"phase":"phase1","phase":"phase1"`, 1))); err == nil { + t.Fatal("duplicate draft field unexpectedly accepted") + } + if _, err := ParseMirrorReceiptDraft([]byte(strings.TrimSuffix(base, "}") + `,"mirror":{}}`)); err == nil { + t.Fatal("operator-supplied mirror identity unexpectedly accepted") + } +} diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index ab8080d..53f171c 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -350,6 +350,80 @@ type ImmutableMirrorReceipt struct { StoredAt string `json:"stored_at"` } +// MirrorReceiptDraft is the human-reviewable, unsigned input produced by a +// mirror after it stores an authenticated accepted-head prefix. It deliberately +// omits the schema and mirror identity: the ceremony derives both rather than +// trusting operator-authored draft fields. +type MirrorReceiptDraft struct { + CeremonyID string `json:"ceremony_id"` + Phase Phase `json:"phase"` + Index uint8 `json:"index"` + AcceptedHeadID string `json:"accepted_head_id"` + Files []ArtifactRef `json:"files"` + StorageLocationSHA256 string `json:"storage_location_sha256"` + StoredAt string `json:"stored_at"` +} + +func (d MirrorReceiptDraft) Validate() error { + if err := validateOperationalScope(d.CeremonyID, d.Phase, d.Index, d.AcceptedHeadID); err != nil { + return err + } + if err := validateMirrorDraftArtifactSet(d.Files); err != nil { + return err + } + if err := validateTaggedHex(d.StorageLocationSHA256, "sha256:", sha256.Size); err != nil { + return fmt.Errorf("storage_location_sha256: %w", err) + } + return validateTimestamp("stored_at", d.StoredAt) +} + +func validateMirrorDraftArtifactSet(artifacts []ArtifactRef) error { + if len(artifacts) == 0 || len(artifacts) > 128 { + return errors.New("files must contain between 1 and 128 artifacts") + } + previous := "" + for index, artifact := range artifacts { + if err := validateArtifactName(artifact.Name); err != nil { + return fmt.Errorf("files %d: %w", index, err) + } + if index > 0 && artifact.Name <= previous { + return errors.New("files must be ordered by unique artifact name") + } + if err := validateTaggedHex(artifact.Digest.SHA256, "sha256:", sha256.Size); err != nil { + return fmt.Errorf("files %d artifact %q sha256: %w", index, artifact.Name, err) + } + if artifact.Digest.Blake2b256 != "" { + if err := validateTaggedHex(artifact.Digest.Blake2b256, "blake2b256:", 32); err != nil { + return fmt.Errorf("files %d artifact %q blake2b256: %w", index, artifact.Name, err) + } + } + if artifact.Digest.Size <= 0 { + return fmt.Errorf("files %d artifact %q size must be positive", index, artifact.Name) + } + previous = artifact.Name + } + return nil +} + +// ParseMirrorReceiptDraft accepts ordinary JSON for operator review while +// still rejecting duplicate/unknown fields and trailing input. Canonical byte +// encoding is produced only after every draft field has been recomputed. +func ParseMirrorReceiptDraft(data []byte) (MirrorReceiptDraft, error) { + if err := rejectDuplicateKeysAndTrailing(data); err != nil { + return MirrorReceiptDraft{}, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var draft MirrorReceiptDraft + if err := decoder.Decode(&draft); err != nil { + return MirrorReceiptDraft{}, fmt.Errorf("decode mirror receipt draft: %w", err) + } + if err := draft.Validate(); err != nil { + return MirrorReceiptDraft{}, err + } + return draft, nil +} + func (r ImmutableMirrorReceipt) Validate() error { if r.Schema != ImmutableMirrorReceiptSchema { return fmt.Errorf("mirror receipt schema %q, want %q", r.Schema, ImmutableMirrorReceiptSchema) @@ -789,10 +863,7 @@ func ValidatePublicWitnessReceipt( closeBytes []byte, receipt PublicWitnessReceipt, ) error { - if err := definition.Validate(); err != nil { - return err - } - if err := close.Validate(); err != nil { + if err := validatePublicWitnessCloseBinding(definition, close); err != nil { return err } if err := receipt.Validate(); err != nil { @@ -831,6 +902,54 @@ func ValidatePublicWitnessReceipt( return nil } +func validatePublicWitnessCloseBinding(definition CeremonyDefinition, close CloseRecord) error { + if err := definition.Validate(); err != nil { + return err + } + if err := close.Validate(); err != nil { + return err + } + if close.CeremonyID != definition.CeremonyID { + return errors.New("closure ceremony does not match authenticated definition") + } + if close.CoordinatorID != definition.Coordinator.ID || + close.CoordinatorKeyID != definition.Coordinator.KeyID { + return errors.New("closure coordinator does not match authenticated definition") + } + if close.BeaconProvider != definition.BeaconPolicy.Provider || + close.BeaconNetwork != definition.BeaconPolicy.Network { + return errors.New("closure beacon does not match authenticated definition policy") + } + createdAt, _ := time.Parse(time.RFC3339Nano, definition.CreatedAt) + closedAt, _ := time.Parse(time.RFC3339Nano, close.ClosedAt) + roundTime, err := QuicknetRoundTime(close.BeaconRound) + if err != nil { + return err + } + if !closedAt.After(createdAt) { + return errors.New("closure must be created after the ceremony definition") + } + if !roundTime.After(closedAt) { + return errors.New("closure beacon round was not in the future when the phase closed") + } + minimumLead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second + if roundTime.Sub(closedAt) < minimumLead { + return fmt.Errorf( + "closure beacon round lead %s is below signed minimum %s", + roundTime.Sub(closedAt), + minimumLead, + ) + } + if requiredLead := requiredCloseLead(definition); roundTime.Sub(closedAt) < requiredLead { + return fmt.Errorf( + "closure beacon round lead %s does not reserve the production witness observation window: need %s", + roundTime.Sub(closedAt), + requiredLead, + ) + } + return nil +} + func ValidateMultiRelayBeaconEvidence( definition CeremonyDefinition, close CloseRecord, diff --git a/internal/mpcceremony/operational_builder.go b/internal/mpcceremony/operational_builder.go index db7cb59..c802116 100644 --- a/internal/mpcceremony/operational_builder.go +++ b/internal/mpcceremony/operational_builder.go @@ -1,8 +1,12 @@ package mpcceremony import ( + "bytes" "encoding/json" + "errors" "fmt" + "slices" + "time" ) // NewEnrollmentRecord derives the frozen definition and full-roster bindings; @@ -137,6 +141,156 @@ func NewImmutableMirrorReceipt( return record, record.Validate() } +// VerifyEnrollmentProofOfPossession authenticates an exact canonical +// enrollment record and its detached signature against the frozen ceremony. +func VerifyEnrollmentProofOfPossession( + definition CeremonyDefinition, + definitionBytes, recordBytes, signatureBytes []byte, +) (EnrollmentRecord, error) { + if err := definition.Validate(); err != nil { + return EnrollmentRecord{}, err + } + canonicalDefinition, err := MarshalCanonical(definition) + if err != nil { + return EnrollmentRecord{}, err + } + if !bytes.Equal(definitionBytes, canonicalDefinition) { + return EnrollmentRecord{}, errors.New("definition bytes are not the exact canonical definition") + } + var record EnrollmentRecord + if err := UnmarshalCanonical(recordBytes, &record); err != nil { + return EnrollmentRecord{}, err + } + signer, err := VerifyOperationalRecordBinding(definition, definitionBytes, &record) + if err != nil { + return EnrollmentRecord{}, err + } + publicKey, err := identityPublicKey(signer) + if err != nil { + return EnrollmentRecord{}, err + } + var signature DetachedSignature + if err := UnmarshalCanonical(signatureBytes, &signature); err != nil { + return EnrollmentRecord{}, err + } + if err := VerifyExact(recordBytes, signature, signer.KeyID, publicKey); err != nil { + return EnrollmentRecord{}, err + } + return record, nil +} + +// MirrorReceiptFiles derives the only artifact set valid for a receipt over an +// accepted chain prefix. +func MirrorReceiptFiles(record ChainRecord, chainPrefix SignedArtifactRefs) ([]ArtifactRef, error) { + if err := record.Validate(); err != nil { + return nil, err + } + if err := chainPrefix.Validate(); err != nil { + return nil, err + } + files := []ArtifactRef{ + record.Attestation, + record.AttestationSignature, + record.Erasure, + record.ErasureSignature, + record.OutputPayload, + record.Verification, + chainPrefix.Record, + chainPrefix.Signature, + } + slices.SortFunc(files, compareArtifactRefName) + if err := validateArtifactSet("files", files); err != nil { + return nil, err + } + return files, nil +} + +// PrepareImmutableMirrorReceipt replaces every authenticated draft field with +// values derived from the ceremony, exact chain prefix, and signed mirror +// enrollment. Any disagreement is rejected before canonical bytes exist. +func PrepareImmutableMirrorReceipt( + definition CeremonyDefinition, + chain Chain, + chainPrefix SignedArtifactRefs, + draft MirrorReceiptDraft, + enrollment EnrollmentRecord, +) (ImmutableMirrorReceipt, []byte, error) { + if err := definition.Validate(); err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if err := chain.ValidateAgainstDefinition(definition); err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if err := draft.Validate(); err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + definitionBytes, err := MarshalCanonical(definition) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, &enrollment); err != nil { + return ImmutableMirrorReceipt{}, nil, fmt.Errorf("mirror enrollment binding: %w", err) + } + if enrollment.Role != EnrollmentMirrorOperator { + return ImmutableMirrorReceipt{}, nil, errors.New("receipt enrollment role must be mirror-operator") + } + if int(draft.Index) != len(chain.Records) { + return ImmutableMirrorReceipt{}, nil, fmt.Errorf( + "receipt index %d must equal authenticated chain prefix length %d", + draft.Index, len(chain.Records), + ) + } + record := chain.Records[len(chain.Records)-1] + acceptedAt, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt) + storedAt, _ := time.Parse(time.RFC3339Nano, draft.StoredAt) + if !storedAt.After(acceptedAt) { + return ImmutableMirrorReceipt{}, nil, errors.New("mirror receipt stored_at must be after accepted head time") + } + expectedFiles, err := MirrorReceiptFiles(record, chainPrefix) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if draft.CeremonyID != definition.CeremonyID || draft.CeremonyID != chain.CeremonyID || + draft.Phase != chain.Phase || draft.AcceptedHeadID != record.RecordID || + !mirrorDraftFilesMatch(draft.Files, expectedFiles) { + return ImmutableMirrorReceipt{}, nil, errors.New("mirror receipt draft does not bind the exact authenticated accepted-head prefix") + } + receipt, err := NewImmutableMirrorReceipt( + definition.CeremonyID, + chain.Phase, + draft.Index, + record.RecordID, + expectedFiles, + enrollment.Identity, + draft.StorageLocationSHA256, + draft.StoredAt, + ) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + canonical, err := MarshalCanonical(receipt) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + return receipt, canonical, nil +} + +func mirrorDraftFilesMatch(draft, expected []ArtifactRef) bool { + if len(draft) != len(expected) { + return false + } + for index := range draft { + if draft[index].Name != expected[index].Name || + draft[index].Digest.SHA256 != expected[index].Digest.SHA256 || + draft[index].Digest.Size != expected[index].Digest.Size || + (draft[index].Digest.Blake2b256 != "" && + draft[index].Digest.Blake2b256 != expected[index].Digest.Blake2b256) { + return false + } + } + return true +} + func NewPublicWitnessReceipt( definition CeremonyDefinition, close CloseRecord, @@ -167,6 +321,45 @@ func NewPublicWitnessReceipt( return record, nil } +// PreparePublicWitnessReceipt derives canonical receipt bytes from an +// authenticated public-witness enrollment and a human's publication claim. +// It hashes the location before record construction and never signs anything. +func PreparePublicWitnessReceipt( + definition CeremonyDefinition, + close CloseRecord, + closeBytes []byte, + enrollment EnrollmentRecord, + closureName, publicationLocation, observedAt string, +) (PublicWitnessReceipt, []byte, error) { + definitionBytes, err := MarshalCanonical(definition) + if err != nil { + return PublicWitnessReceipt{}, nil, err + } + if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, &enrollment); err != nil { + return PublicWitnessReceipt{}, nil, fmt.Errorf("witness enrollment binding: %w", err) + } + if enrollment.Role != EnrollmentPublicWitness { + return PublicWitnessReceipt{}, nil, errors.New("receipt enrollment role must be public-witness") + } + receipt, err := NewPublicWitnessReceipt( + definition, + close, + closeBytes, + enrollment.Identity, + closureName, + taggedSHA256([]byte(publicationLocation)), + observedAt, + ) + if err != nil { + return PublicWitnessReceipt{}, nil, err + } + canonical, err := MarshalCanonical(receipt) + if err != nil { + return PublicWitnessReceipt{}, nil, err + } + return receipt, canonical, nil +} + func NewMultiRelayBeaconEvidence( definition CeremonyDefinition, close CloseRecord, diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index 49e2a31..590ef72 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -708,25 +708,11 @@ func verifyEnrollmentEvidence( if err != nil { return nil, nil, fmt.Errorf("enrollment %d signature: %w", index, err) } - var record EnrollmentRecord - if err := UnmarshalCanonical(recordBytes, &record); err != nil { - return nil, nil, fmt.Errorf("enrollment %d: %w", index, err) - } - signer, err := VerifyOperationalRecordBinding(definition, definitionBytes, &record) + record, err := VerifyEnrollmentProofOfPossession(definition, definitionBytes, recordBytes, signatureBytes) if err != nil { - return nil, nil, fmt.Errorf("enrollment %d binding: %w", index, err) - } - publicKey, err := identityPublicKey(signer) - if err != nil { - return nil, nil, err - } - var signature DetachedSignature - if err := UnmarshalCanonical(signatureBytes, &signature); err != nil { - return nil, nil, err - } - if err := VerifyExact(recordBytes, signature, signer.KeyID, publicKey); err != nil { return nil, nil, fmt.Errorf("enrollment %d proof of possession: %w", index, err) } + signer := record.Identity if _, err := verifyArtifactBytes(root, record.IndependenceDisclosure, 1<<20); err != nil { return nil, nil, fmt.Errorf("enrollment %d independence disclosure: %w", index, err) } @@ -1042,14 +1028,10 @@ func verifyAcceptedHeadEvidence( mirrorIDs := make(map[string]struct{}, len(evidence.MirrorReceipts)) mirrorKeys := make(map[string]struct{}, len(evidence.MirrorReceipts)) - expectedMirrorFiles := append([]ArtifactRef(nil), expectedReturnFiles...) - expectedMirrorFiles = append( - expectedMirrorFiles, - record.Verification, - evidence.AcceptedChainPrefix.Record, - evidence.AcceptedChainPrefix.Signature, - ) - slices.SortFunc(expectedMirrorFiles, compareArtifactRefName) + expectedMirrorFiles, err := MirrorReceiptFiles(record, evidence.AcceptedChainPrefix) + if err != nil { + return nil, fmt.Errorf("accepted head %d mirror files: %w", index+1, err) + } for mirrorIndex, pair := range evidence.MirrorReceipts { mirrorAny, mirrorRefs, err := verifyOperationalPair( definition, diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index bccf060..d22ef33 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -423,33 +423,58 @@ type PhaseTranscriptPaths struct { // LoadSignedChain verifies the exact coordinator-signed chain at paths. func LoadSignedChain(trusted *TrustedCeremony, paths PhaseTranscriptPaths) (Chain, error) { + chain, _, err := LoadSignedChainExact(trusted, paths) + return chain, err +} + +// LoadSignedChainExact verifies a coordinator-signed chain and returns artifact +// references computed from the same exact bytes that were authenticated. +func LoadSignedChainExact(trusted *TrustedCeremony, paths PhaseTranscriptPaths) (Chain, SignedArtifactRefs, error) { if err := validateTrustedCeremony(trusted); err != nil { - return Chain{}, err + return Chain{}, SignedArtifactRefs{}, err } if strings.TrimSpace(paths.RootDir) == "" || strings.TrimSpace(paths.ChainPath) == "" || strings.TrimSpace(paths.ChainSignaturePath) == "" { - return Chain{}, errors.New("transcript root, chain, and chain signature paths are required") + return Chain{}, SignedArtifactRefs{}, errors.New("transcript root, chain, and chain signature paths are required") + } + chainName, err := logicalPathWithin(paths.RootDir, paths.ChainPath) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("chain path: %w", err) } - if _, err := logicalPathWithin(paths.RootDir, paths.ChainPath); err != nil { - return Chain{}, fmt.Errorf("chain path: %w", err) + signatureName, err := logicalPathWithin(paths.RootDir, paths.ChainSignaturePath) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("chain signature path: %w", err) } - if _, err := logicalPathWithin(paths.RootDir, paths.ChainSignaturePath); err != nil { - return Chain{}, fmt.Errorf("chain signature path: %w", err) + chainBytes, err := readRegularBounded(paths.ChainPath, maxSignedRecordBytes) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("load signed chain: %w", err) + } + signatureBytes, err := readRegularBounded(paths.ChainSignaturePath, maxSignedRecordBytes) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("load signed chain: %w", err) } var chain Chain - if err := loadCoordinatorSignedRecord( - trusted, - paths.ChainPath, - paths.ChainSignaturePath, + if err := VerifySignedRecord( + chainBytes, + signatureBytes, &chain, + trusted.Definition.Coordinator.KeyID, + trusted.CoordinatorPublicKey, ); err != nil { - return Chain{}, fmt.Errorf("load signed chain: %w", err) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("load signed chain: %w", err) } if err := chain.ValidateAgainstDefinition(trusted.Definition); err != nil { - return Chain{}, fmt.Errorf("chain against definition: %w", err) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("chain against definition: %w", err) } - return chain, nil + refs := SignedArtifactRefs{ + Record: ArtifactRef{Name: chainName, Digest: NewDigest(chainBytes)}, + Signature: ArtifactRef{Name: signatureName, Digest: NewDigest(signatureBytes)}, + } + if err := refs.Validate(); err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + return chain, refs, nil } // LoadReplayPhase1Files strictly reads all accepted evidence and replays every From 7a652a60055fee6df7dfe750cf2589ab4df66d74 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Tue, 18 Aug 2026 09:50:07 +0000 Subject: [PATCH 22/42] Refresh and compress the attack/defense inventory Fold the audit's fixes into the defense list, replace the stale known-gaps section with the four items actually open, and cut the prose to anchors: one line per defense, section intros dropped, fixed items collapsed into a single list. 634 lines to under 200. --- docs/mpc-ceremony-security-defenses.md | 799 ++++++------------------- 1 file changed, 167 insertions(+), 632 deletions(-) diff --git a/docs/mpc-ceremony-security-defenses.md b/docs/mpc-ceremony-security-defenses.md index 92a0784..783ce45 100644 --- a/docs/mpc-ceremony-security-defenses.md +++ b/docs/mpc-ceremony-security-defenses.md @@ -1,634 +1,169 @@ # MPC Ceremony — Attack/Defense Inventory -A survey of the deliberate security defenses implemented in the ceremony codebase -(`internal/mpcceremony`, `internal/streampk`, `internal/msmengine`, -`internal/keybundle`, `cmd/mpc-ceremony`, `cmd/wasm-prover`), each mapped to the -attack it counters, with code citations. Known gaps are listed at the end. - -Line numbers are as of the commit this document was written against; treat them -as anchors, not guarantees. - -## ELI5 - -The ceremony is a group of people taking turns stirring secret ingredients into -a shared pot, and the final recipe is only safe if at least one person's -ingredient stays secret and nobody swaps the pot when no one is looking. Almost -every defense below is one of these five ideas: - -1. **Never trust a label, always check the contents.** Every file, key, and - record carries a fingerprint (hash), and the code re-computes and compares - that fingerprint every single time it touches the thing — not just once at - the start. A swapped file is caught even if it has the right name. -2. **Never trust a path.** A file path can secretly be a signpost (symlink) - pointing somewhere else, and a file can be swapped in the instant between - "check it" and "open it." The code looks before opening, opens, then looks - again to make sure it's still the same file. -3. **Write once, never overwrite.** Ceremony history is append-only. New - records link to the previous one by fingerprint (like a blockchain), so - rewriting, reordering, or deleting history breaks the chain visibly. - Publishing uses "create only if it doesn't exist" operations so nothing - authoritative can ever be silently replaced. -4. **One person can't cheat alone.** The coordinator, release signer, auditors, - and participants must all be different people with different keys; releases - need multiple independent sign-offs; and the random beacon comes from a - public source (drand) chosen far enough in the future that nobody can know - it in advance. -5. **Assume the input is hostile.** Every byte parsed — JSON, curve points, - sizes, timestamps — is checked for exactly one canonical form, exact length, - and sane bounds before it's used. Two different encodings of "the same" - thing are treated as an attack, not a convenience. - -The known gaps section at the end lists the handful of places where these -ideas are not yet applied consistently. - -## 1. Filesystem - -### Symlink attacks (CWE-59) - -Attack: plant a symlink at an expected path so the tool reads or writes -somewhere else (another user's key, `/etc/passwd`, an attacker-controlled file). - -- `openRegularExact` Lstats and rejects `ModeSymlink` and non-regular files - before opening — `internal/mpcceremony/files.go:127-133` -- `readRegularBounded` same pattern for signed records and keys — - `internal/mpcceremony/workflow.go:2165-2174` -- Publication file/tree inspection rejects symlinks and non-regular entries — - `internal/mpcceremony/publication.go:101-106,301,322` -- Key bundle reads require a regular file with secret permissions — - `internal/keybundle/keybundle.go:232-244` -- CLI inputs reject symlinks — `cmd/mpc-ceremony/ops.go:309-314`, - `cmd/mpc-ceremony/executor.go` (`readPublicKeyHex`) -- Walk/copy paths reject symlink entries — - `internal/mpcceremony/audit.go:1517-1519`, `decision.go:1068`, - `finalize.go:1741-1746` -- `rejectSymlinkComponents` Lstats every parent path component and rejects any - symlink or non-directory intermediate; its doc comment explicitly disclaims - race-freeness versus `openat2(RESOLVE_NO_SYMLINKS)` — - `internal/mpcceremony/workflow.go:2650-2684` - -### TOCTOU races (CWE-367) - -Attack: swap the file between the check and the open, or mutate it while it is -being read or hashed. - -- `os.SameFile(linkInfo, info)` re-check after open ("changed while being - opened") — `internal/mpcceremony/files.go:141-153` -- SameFile + size check + trailing one-byte read ("changed while being read") — - `internal/mpcceremony/workflow.go:2180-2200` -- SameFile before hashing, size stability during, SameFile + size again after - ("changed while being hashed") — `internal/mpcceremony/publication.go:121-150` -- Tree inspection re-Lstats the root after the walk to detect a mid-walk swap — - `internal/mpcceremony/publication.go:296-356` -- `copyRegularNoReplace` triple-checks source identity/size before, during, and - after the copy — `internal/mpcceremony/audit.go:1416-1468` -- Running-executable digest re-checks size mid-hash — - `internal/mpcceremony/software.go:343-370` -- Key bundle reads: SameFile + size + trailing-byte read — - `internal/keybundle/keybundle.go:250-267` -- Key manifest re-compared (`reflect.DeepEqual`) after signature verification - ("manifest changed after signature verification") — - `internal/keybundle/keybundle.go:141-146` - -### Path traversal / containment (CWE-22) - -Attack: artifact names or URLs that escape the intended directory -(`../../…`, absolute paths, scheme smuggling). - -- `validateArtifactName`: rejects `\`, leading `/`, non-clean paths, `.`; - bounds length and requires UTF-8 — `internal/mpcceremony/model.go:543-551` -- `resolveArtifactPath`: absolute-path + `filepath.Rel` containment (rejects - `..` escapes) + symlink-component rejection — - `internal/mpcceremony/workflow.go:2605-2625` -- `logicalPathWithin` for outputs rejects `.`/`..`/escapes — - `internal/mpcceremony/workflow.go:2627-2648` -- `safeRelativePath` rejects absolute paths, `\`, `://`, `?`, `#`, non-clean — - `internal/proofassets/chunk_manifest.go:920-923` -- `resolveChunkURL` rejects `\`, `://`, `?`, `#`, `../`, non-clean; requires an - absolute base URL with scheme and host — - `internal/msmengine/sharded_js.go:367-390` -- Path flags reject `-` (stdin) and URLs — `cmd/mpc-ceremony/parse.go:955-966` - -### Overwrite / partial-state attacks on authoritative records - -Attack: replace, truncate, or roll back already-published ceremony state; leave -a torn write that later reads as valid. - -- `atomicWriteNoReplace`: temp file in the same directory, 0600, size check, - fsync, strict read-back validation, hard-link publish (never replaces) — - `internal/mpcceremony/files.go:266-336` -- `publishFileWithOps`: `link()` publish, destination identity via SameFile, - byte and mode revalidation, parent fsync with recovery retry — - `internal/mpcceremony/publication.go:168-287` -- Directory publication via `RENAME_NOREPLACE`; rejects empty staging; - idempotent recovery only for a byte-exact existing tree — - `internal/mpcceremony/publication.go:378-525` -- `publicationError` commit-state tracking so a committed publication is never - rolled back by cleanup defers — `internal/mpcceremony/publication.go:18-46` - (used at `workflow.go:289,689,1822,1958`) -- `O_WRONLY|O_CREATE|O_EXCL` with 0600 for new files — - `internal/mpcceremony/audit.go:1437`, `finalize.go:1872-1911` -- `requireAbsentOrExact`: a retry may only succeed against a byte-identical - existing artifact; any mismatch aborts — - `internal/mpcceremony/workflow.go:2230-2256` -- Signature published before its record, so a record can never exist without - its signature — `internal/mpcceremony/workflow.go:2209-2227` -- Durability: `syncDirectory` — `internal/mpcceremony/files.go:383-393`; - fsync-failure recovery re-validates before retrying — - `internal/mpcceremony/publication.go:527-552` - -### Permissions - -Attack: key material readable by other local users. - -- `requirePrivateRealDirectory` rejects group/world permission bits — - `internal/mpcceremony/workflow.go:2401-2413` -- `mkdirAllPrivateDurable`: 0700, per-level real-directory checks, parent - fsync — `internal/mpcceremony/workflow.go:2460-2498` -- Directory-member allowlist; only `..partial-*` temporaries may be - reaped — `internal/mpcceremony/workflow.go:2415-2458` -- Private key files must be mode 0600 or stricter — - `internal/keybundle/keybundle.go:239-241` - -### Resource exhaustion - -Attack: oversized inputs exhaust memory or disk. - -- `MaxArtifactSize` = 16 GiB, fail-closed — - `internal/mpcceremony/preflight.go:28,188-196` -- Signed records capped at 16 MiB — `internal/mpcceremony/workflow.go:25`; - drand responses at 1 MiB — `internal/mpcceremony/beacon.go:17` -- File sizes must be in `[1, max]` — `internal/mpcceremony/workflow.go:2190-2192` -- Per-file bound and 100,000-entry tree cap in publication — - `internal/mpcceremony/publication.go:108-115,304-311` -- 4096-byte caps on signature/public-key artifacts — - `internal/mpcceremony/decision.go:815,819` -- Per-artifact-type byte caps — `internal/keybundle/keybundle.go:27-31` - -## 2. Cryptographic - -### Forged or replayed records - -Attack: fabricate a signed record, or trust a key named inside the (untrusted) -record itself. - -- `VerifyExact`: schema/algorithm validation, key-ID match, public-key - fingerprint match, signed-data SHA-256 match, then `ed25519.Verify` — - `internal/mpcceremony/attestation.go:70-97` -- `VerifySignedRecord`: authenticate the exact bytes before strict parsing — - `internal/mpcceremony/attestation.go:117-131` -- `LoadSignedDefinition`: requires an external out-of-band coordinator public - key (an in-tree copy is insufficient); the signature's `KeyID` is deliberately - not trusted for role assignment until the external anchor has authenticated - the bytes; identity key cross-checked against the anchor — - `internal/mpcceremony/workflow.go:179-230` -- Offline operational signatures verified over exact canonical bytes before - wrapping — `internal/mpcceremony/operational.go:584-614` - -### Key substitution - -Attack: swap in a different key for an enrolled identity. - -- `identityPublicKey` re-derives and checks the fingerprint on every load — - `internal/mpcceremony/workflow.go:2138-2147` -- Loaded private key must match the enrolled identity's public key — - `internal/mpcceremony/workflow.go:2149-2162` -- Decision signing key must equal the required ceremony identity — - `internal/mpcceremony/decision.go:617-620` -- A 64-byte private key's public half must match its seed derivation — - `internal/keybundle/keybundle.go:194-199` - -### Artifact substitution - -Attack: hand the verifier different bytes than were signed. - -- Every `Digest` carries SHA-256 + BLAKE2b-256 + exact size; tagged lowercase - hex enforced — `internal/mpcceremony/model.go:76-104` -- Every referenced artifact re-hashed against its signed ref before use — - `internal/mpcceremony/workflow.go:2686-2699` -- R1CS digested before native decoding (vector lengths are unsafe from an - unauthenticated file) — `internal/mpcceremony/r1cs.go:271-302` (comment at - 84-87) -- Circuit binding requires exact match of both hashes and serialization size — - `internal/mpcceremony/r1cs.go:68-82` -- Running tool binary must digest-match the signed software binding — - `internal/mpcceremony/software.go:321-330` -- CCS pinned by blake2b/sha256/size against the signed manifest — - `cmd/wasm-prover/main_js.go:1024-1033` - -### Encoding-equivalence attacks - -Attack: two different byte encodings that decode to the same object, defeating -digest-based identity. - -- `requireCanonicalRoundTrip`: re-serialize the decoded gnark object and - require byte-identical size plus both digests — - `internal/mpcceremony/files.go:191-216` -- `streamClone` round-trips through a pipe with byte-count and trailing-byte - equality — `internal/mpcceremony/phase1.go:259-310` - -### Invalid curve points / small subgroups - -Attack: a point that parses but sits outside the prime-order subgroup leaks -secrets via Pohlig–Hellman over the cofactor (the ZKHack trusted-setup -primitive). - -- BLS12-381 compressed-point flag-byte check rejects non-canonical prefixes — - `internal/mpcceremony/preflight.go:427-438` -- Ceremony path uses gnark-crypto decoder defaults with subgroup checks ON, - and `UpdateProof.Verify` additionally runs `IsInSubGroup()` and rejects - infinity (upstream `mpcsetup.go:94-99`) -- `msmengine` pinned decoders skip the subgroup check only on - digest-authenticated bytes and explicitly re-add `IsOnCurve()` per point — - `internal/msmengine/serialize.go:103-122,139-158`; the non-pinned siblings - use `SetBytes` (full validation) — `serialize.go:85-98,124-137` - -### Cross-protocol / context confusion - -Attack: a hash or signature computed for one record type accepted as another. - -- `canonicalHash(domain, value)`: per-record-type domain tag + `0x00` - separator + canonical JSON — `internal/mpcceremony/model.go:421-431`. - Distinct tags for root, phase, acceptance, genesis, close, beacon, seal, - audit, final-transcript, contribution/erasure attestations, signed release, - production decision, and full replay (see `definition.go`, `chain.go`, - `attestation.go`, `decision.go`, `audit.go`) -- `DeriveBeaconChallenge`: domain tag + `0x00`, 4-byte big-endian length - prefix on every variable-length field, 8-byte BE round — unambiguous tuple - encoding — `internal/mpcceremony/chain.go:790-825` -- Public-input digest domain-prefixed — - `internal/mpcceremony/finalize.go:1367-1374` - -### ID substitution - -Attack: reuse a record's contents under a different record ID. - -- Every record ID is content-addressed: recomputed over the record with the ID - field blanked, mismatch rejected, and the ID field required to be empty - during computation — `internal/mpcceremony/chain.go:60-72` (and the parallel - checks in `definition.go`, `attestation.go`, `finalize.go`, `decision.go`) - -### Rigged randomness beacon - -Attack: operator supplies or biases the public randomness. - -- Drand quicknet chain hash, public key, scheme, genesis, and period pinned in - the signed definition — `internal/mpcceremony/model.go:317-355` -- `VerifyDrandBeaconResponse`: real BLS verification against the pinned key; - randomness derived as `sha256(verified signature)`, never taken from the - response; unchained schemes' `previous_signature` rejected — - `internal/mpcceremony/beacon.go:44-107` -- Caller-supplied challenge values rejected unless equal to the deterministic - derivation — `internal/mpcceremony/chain.go:629-634` - -### A verifier that accepts anything - -Attack: a broken or stubbed verifier reports success on garbage. - -- Negative-control verification at finalization: after the positive check, the - verifier must *reject* a changed destination, changed credential, changed - digest, bit-flipped proof, wrong verifying key, truncated proof, and - appended proof; all eight report booleans required true — - `internal/mpcceremony/finalize.go:1313-1363,223-232` -- Wrong-key negative control negates `G1.K[0]` (mutating `Alpha` would not be - a valid negative test because the verifier uses the precomputed pairing) — - `internal/mpcceremony/finalize.go:1426-1455` - -### Crash-as-oracle / denial via panic - -- Panic boundaries around gnark decode/verify of untrusted input — - `internal/mpcceremony/files.go:338-381`, - `internal/mpcceremony/phase1.go:312-336` - -## 3. Serialization - -Attack class: JSON smuggling (duplicate keys, unknown fields, trailing data), -non-canonical encodings that alias distinct digests, length-field lies, -integer overflow. - -- `MarshalCanonical`: rejects nil and `map[string]any`; requires `Validate()` — - `internal/mpcceremony/model.go:363-382` -- `UnmarshalCanonical`: duplicate-key scan, `DisallowUnknownFields`, - trailing-token rejection, `Validate()`, then re-marshal and require byte - equality with the input — `internal/mpcceremony/model.go:386-419` -- Recursive duplicate-key detection with `UseNumber()` — - `internal/mpcceremony/model.go:433-501` -- `strictjson`: max depth 64, max 100,000 object keys, duplicate-key and - trailing-value rejection — `internal/strictjson/strictjson.go:14-17,75-106` -- Drand JSON parsed strictly before any crypto — - `internal/mpcceremony/beacon.go:58-69,109-118` -- `nativeReadExact`: `io.LimitedReader` at the exact expected size; decoder - must consume exactly that and leave zero trailing bytes — - `internal/mpcceremony/files.go:172-189` -- Preflight scanner tracks consumed bytes, rejects overrun, and proves EOF - with a one-byte read — `internal/mpcceremony/preflight.go:383-393,497-509` -- `checkedAdd`/`checkedMul`/`checkedSub` via `math/bits` for all size - arithmetic — `internal/mpcceremony/preflight.go:198-219` -- `MaxDomainN = 2^32` (BLS12-381 2-adicity), `MaxPhase2Commitments = 255` - (gnark's 1-byte commitment domain tag aliases beyond that) — - `internal/mpcceremony/preflight.go:20-24` -- Phase 2 shape must come from the locally compiled R1CS, never from an - untrusted artifact — `internal/mpcceremony/preflight.go:57-63`, enforced at - `workflow.go:2707-2747` and `files.go:103-124` -- Stream length prefixes must equal locally derived expected lengths before - any allocation — `internal/mpcceremony/preflight.go:458-470` -- streampk domain header: canonical-flag byte check, trailing-byte rejection, - every FFT domain field recomputed against `fft.NewDomain` — - `internal/streampk/keysource.go:163-217` -- Timestamps must be UTC `Z` and round-trip canonically through RFC3339Nano — - `internal/mpcceremony/model.go:553-565` -- Hex must be exact-length lowercase (rejects mixed-case aliasing) — - `internal/mpcceremony/model.go:510-522` - -## 4. Identity / roster - -Attack class: one actor holding multiple roles (Sybil), colluding role -overlap, duplicate enrollment. - -- Release signer distinct from coordinator by ID and key ID — - `internal/mpcceremony/definition.go:161-163` -- At least two auditors; uniqueness across coordinator/release signer/auditors - in three dimensions: identity ID, key ID, public-key fingerprint — - `internal/mpcceremony/definition.go:164-198` -- Roster uniqueness against all prior roles, same three dimensions — - `internal/mpcceremony/definition.go:199-225` -- Same three-dimension uniqueness re-applied at enrollment input — - `internal/mpcceremony/workflow.go:68-123` -- Phase policy: non-empty, ≤ 20 participants, minimum within bounds, all IDs - in roster, no duplicates — `internal/mpcceremony/model.go:177-198` -- A participant may appear at most once per phase chain — - `internal/mpcceremony/chain.go:205-208` -- Exactly two enrolled audits by distinct auditors with distinct key IDs, plus - two external audits with distinct signer fingerprints — - `internal/mpcceremony/decision.go:487-515` -- External auditor keys disjoint from coordinator, release signer, and all - enrolled auditors — `internal/mpcceremony/decision.go:792-803` -- GO decision requires exactly the required signer set — no extras, none - missing; duplicate signatures rejected — - `internal/mpcceremony/decision.go:683-716` -- Public witnesses and mirror operators must not overlap any ceremony actor — - `internal/mpcceremony/operational.go:937-950` -- Transfer sender/recipient distinct — `internal/mpcceremony/operational.go:1007-1024` -- IDs restricted to `[a-z0-9-_.:]`, 1–128 chars — - `internal/mpcceremony/model.go:531-541` - -## 5. Transcript / chain integrity - -Attack class: rewrite, reorder, fork, or truncate ceremony history; splice a -contribution that was never verified. - -- `Chain.Validate`: strictly increasing timestamps, contiguous 1-based - indices, `PreviousPayload` = accepted head, `PreviousRecordID` = prior - record ID (hash chaining), ceremony/phase identity match, ≤ 20 records — - `internal/mpcceremony/chain.go:159-214` -- `Append` validates the entire candidate chain before mutating — - `internal/mpcceremony/chain.go:216-227` -- Accepted payload must differ from the previous payload (no no-op - contributions) — `internal/mpcceremony/chain.go:106-108,374-376` -- Domain-separated genesis anchor — `internal/mpcceremony/chain.go:382-398` -- Chain participants must match the frozen scheduled order from the signed - definition — `internal/mpcceremony/chain.go:283-297` -- `ValidateAttestationAcceptance`: record must be the next child of the head - (index, payload, and record ID all three); 10-field binding between record - and attestation; software binding equality; full chronology (contributed - after created, after previous acceptance; accepted after destruction) — - `internal/mpcceremony/chain.go:301-380` -- gnark contribution challenge must equal SHA-256 of the previous payload — - binds the native transcript to the JSON chain — - `internal/mpcceremony/workflow.go:2865-2877` -- `verifyChainFiles`: every record's native payload re-digested; participant - attestation, erasure, and coordinator verification records verified; - growing-prefix revalidation — `internal/mpcceremony/workflow.go:2701-2828` -- Full replay from deterministic genesis with per-step `previous.Verify(next)`; - clone-before-verify so archived inputs are never mutated — - `internal/mpcceremony/phase1.go:145-205`, `phase2.go:228-292` -- Replayed shape must equal the signed circuit binding — - `internal/mpcceremony/phase2.go:264-268` -- Erasure attestation binds the contribution in 8 fields; destruction must - postdate contribution — `internal/mpcceremony/attestation.go:257-280` -- Coordinator verification record must match the chain record field-for-field — - `internal/mpcceremony/workflow.go:1323-1343` -- Transfer receipts bind `sha256(exact handoff bytes)` plus 10 scope fields, - with a validity window — `internal/mpcceremony/operational.go:709-724` -- Operational evidence must cover every accepted head and terminate at the - close record's head — `internal/mpcceremony/operational_bundle.go:544-549` - -## 6. Network / download - -- `internal/mpcceremony` imports no networking; verification never fetches a - URI or trusts mutable network state — - `internal/mpcceremony/decision.go:82-84` -- Evidence URIs restricted to `https`/`ipfs`, canonical encoding, no userinfo, - no fragment, host required, ≤ 2048 bytes; recorded, never fetched — - `internal/mpcceremony/decision.go:1322-1342` -- `Content-Encoding` must be empty or `identity` (blocks transparent- - decompression length/digest confusion) — - `internal/msmengine/sharded_js.go:326-328`, - `apps/ownership-proof-web/public/proof-runtime/msm-worker.js:313-326` -- Exact-size reads via `LimitReader(size+1)` — - `internal/msmengine/sharded_js.go:329-335` -- Dual-digest chunk verification before use; verify-before-cache (no error - path can populate the LRU) — `internal/msmengine/sharded_js.go:337-364`, - `msm-worker.js:313-326` -- Compressed CCS: wire bytes hashed and length-checked against a signed pin - while inflating; trailer drained; mismatch falls back to the fully pinned - identity asset (cannot downgrade integrity) — - `cmd/wasm-prover/main_js.go:1187-1211` -- Unpinned compile fallback refused when `ccs_url` is absent — - `cmd/wasm-prover/main_js.go:1043` -- Manifest signature URL and public key must be supplied together — - `cmd/wasm-prover/main_js.go:1449-1495` -- Readahead discards bodies; integrity enforced only at consumption — - `cmd/wasm-prover/readahead_js.go:14-21` -- Section byte ranges bounds-checked against the plan's file size — - `internal/msmengine/sharded_js.go:282-284` - -## 7. Process / operational - -### Beacon precommitment - -Attack: coordinator who already knows the beacon output closes the phase -around it. - -- `beacon_not_before` must postdate close and exactly equal the pinned - quicknet round schedule — `internal/mpcceremony/chain.go:493-509` -- Round must be in the future at close; lead ≥ signed minimum — - `internal/mpcceremony/chain.go:567-589` -- Lead re-checked immediately before the atomic publish, with a 2-second - safety margin and a clock-monotonicity check — - `internal/mpcceremony/workflow.go:1538-1583,28` -- Production requires ≥ 24h witness lead — - `internal/mpcceremony/definition.go:8,232-239` -- Phase 2 beacon round must differ from Phase 1's (no round reuse) — - `internal/mpcceremony/workflow.go:1409-1414` -- Beacon `published_at` must not precede the committed time or round schedule — - `internal/mpcceremony/chain.go:755-764` -- Challenge must be exactly 32 bytes; future-round requirement mandatory — - `internal/mpcceremony/model.go:345-353` -- Round-time arithmetic overflow-checked — - `internal/mpcceremony/chain.go:773-785` - -### Quorum weakening - -- Public-witness quorum ≥ 2; receipts must meet it, with witness ID and key - fingerprint de-duplication and unanimity on closure and round — - `internal/mpcceremony/operational.go:741-781`, - `operational_bundle.go:110-119` -- Multi-relay beacon: 3–16 observations, distinct relay IDs, distinct - operator IDs, distinct endpoint digests, unanimous verified randomness — - `internal/mpcceremony/operational.go:394-427` -- 2–8 immutable mirror receipts per accepted head — - `internal/mpcceremony/operational_bundle.go:72-75` -- ≥ 2 independent audits — `internal/mpcceremony/chain.go:1174-1176`, - `audit.go:867-868` - -### Production-mode hardening - -- Production requires all scheduled participants accepted (rehearsal permits - ≥ minimum); ≥ 2 roster participants and ≥ 2 scheduled per phase with - `minimum == len(participants)` — `internal/mpcceremony/chain.go:530-539`, - `definition.go:240-254` - -### Supply chain - -- Production requires a clean git tree and exact build profile: pinned Go - version, GOOS/GOARCH/GOAMD64, compiler, buildmode, `CGO_ENABLED=false`, - `trimpath` — `internal/mpcceremony/software.go:433-463`, - `definition.go:124-139` -- VCS must be git; revision 40 lowercase hex, not all-zero; `vcs.modified` - false in production — `internal/mpcceremony/software.go:172-208,491-504` -- Module `replace` directives rejected in production; duplicate build - settings and linked modules rejected — - `internal/mpcceremony/software.go:383-400,465-489` -- Production executable identity read from `/proc/self/exe` — - `internal/mpcceremony/software.go:41-50` -- Running software re-verified against the signed definition on every - operational command — `internal/mpcceremony/workflow.go:232-244` - -### Separation of duties - -- Release signing requires ≥ 2 distinct enrolled passing audits and a - distinct pre-existing release key; release directory must differ from the - candidate directory — `internal/mpcceremony/audit.go:277-343` -- Audits must bind the exact candidate replay root and output set, and - postdate candidate finalization — `internal/mpcceremony/audit.go:862-956` -- Release must strictly postdate every audit — - `internal/mpcceremony/audit.go:958-963` -- Release self-verified via full `VerifyRelease` before publication — - `internal/mpcceremony/audit.go:469-479` -- `PrepareFinalization` output is explicitly not a candidate and is rejected - by audit/release commands — `internal/mpcceremony/finalize.go:451-455` -- "Trust the published seal" shortcut restricted to coordinator acceptance; - contribution/close/finalize/audit paths must independently replay Phase 1 - before sampling secret randomness — - `internal/mpcceremony/workflow.go:2879-2885` -- GO decision requires coordinator + both auditors + release signer, exactly — - `internal/mpcceremony/decision.go:705-716,1253-1260` - -### Contribution environment and erasure - -- Contribution attestation requires OS CSPRNG, swap disabled, crash dumps - disabled, telemetry disabled, ephemeral environment, destruction plan — - `internal/mpcceremony/attestation.go:144-156` -- Erasure attestation requires process termination, ephemeral storage - destroyed, no backup retained — - `internal/mpcceremony/attestation.go:249-251` - -### Ordering of secret sampling - -- All deterministic preflights complete before MPC entropy is sampled; the - candidate directory is created after replay so a crash cannot strand an - empty candidate — `internal/mpcceremony/workflow.go:675-692` -- Participant must be the one scheduled at the exact index — - `internal/mpcceremony/workflow.go:664-668` - -### Release / evidence tree exactness - -- `verifyReleaseTreeExact`: no unexpected, missing, symlinked, or non-regular - entries — `internal/mpcceremony/audit.go:1486-1543` -- Release tree walk rejects any unpinned file; every pinned artifact must be - present with the exact digest — `internal/mpcceremony/decision.go:1043-1103` -- `verifyChecksumsExact`: exact entry count, sorted order, no duplicates, - digest re-verification — `internal/mpcceremony/audit.go:1021-1071` -- Release artifacts strictly ordered by unique logical name, 16–4096 files — - `internal/mpcceremony/decision.go:196-205,1035-1039` -- One name / one URI may not map to conflicting evidence — - `internal/mpcceremony/decision.go:1262-1276` - -### Governance - -- Restart must bind a genuinely fresh ceremony ID; `new_ceremony_id` - forbidden on non-restart records — - `internal/mpcceremony/operational.go:493-502,885-905` -- Passing audit must have zero findings; failing audit ≥ 1 — - `internal/mpcceremony/chain.go:1031-1041` - -## 8. Other - -- **CLI error redaction**: every caller-supplied argument value replaced with - `` in diagnostics (unexpected positionals can be seed phrases); - longest-first replacement avoids partial-substring leaks — - `cmd/mpc-ceremony/main.go:140-176` -- **Secret exclusion from published evidence**: master XPrv, seed, derivation - path, and wallet material excluded from `PublicFinalizationEvidence` — - `internal/mpcceremony/finalize.go:262-265` -- **Golden-vector pinning**: public evidence must use the exact repository - golden public vector — `internal/mpcceremony/finalize.go:289-292` -- **No mutable discovery**: fixed sidecar paths; no `latest` lookup or - directory scan — `internal/mpcceremony/workflow.go:34-42`, - `finalize.go:63-65` -- **Fail-closed release verification**: requires an out-of-band trusted public - key; refuses to verify without the native proving key — - `internal/mpcceremony/audit.go:504-509` -- **Integer/type safety on 32-bit wasm**: `nbWires` overflow guard — - `internal/streampk/keysource.go:143-145`; Phase 2 shape derivation overflow - guards — `internal/mpcceremony/r1cs.go:352-386` - -## Known gaps - -1. **Ed25519 identity keys are not validated as curve points — FIXED - 2026-08-13.** `Identity.Validate` previously checked only that the key is - 32 bytes of hex. Small-order/non-canonical points were accepted, and stdlib - `ed25519.Verify` (`attestation.go:93`) does not reject small-order keys — a - small-order public key admits signatures that verify for any message. - Non-canonical encodings would also have evaded the fingerprint-based - duplicate-key detection (`definition.go:218`). Now fixed: - `validateEd25519PublicKey` (`internal/mpcceremony/model.go`) decodes with - `filippo.io/edwards25519`, requires canonical encoding (re-encoded bytes - must equal input), and rejects small-order points via - `MultByCofactor == identity`. -2. **`streampk` URL path skips subgroup checks with no compensating - verification.** `internal/streampk/keysource.go:116,133,378,393` use - `NoSubgroupChecks()` with no `IsOnCurve` and no digest verification on the - URL path. Documented as finding D2 in - `docs/mpc-ceremony-proposed-changes.md:255-329`. -3. **`Identity.DisplayName` is unbounded and permits control characters — - FIXED 2026-08-15.** `Identity.Validate` checked only trimming and UTF-8 - validity, so there was no length cap and interior ANSI escapes, bidi - overrides, and zero-width characters passed into signed records, logs, and - transcripts. `validateArtifactName` was partially hardened 2026-08-13 - (512-byte cap, `unicode.IsControl`, no untrimmed path segments) but shared - the same blind spot, because `unicode.IsControl` reports Unicode category - **Cc** only, while every bidi and zero-width character is category **Cf**. - - Both validators now share `rejectDeceptiveRunes` (`model.go:643-674`), which - rejects control characters, the bidi formatting set - (`U+202A`-`U+202E`, `U+2066`-`U+2069`, `U+200E`, `U+200F`), and `U+200B`. - `validateDisplayName` (`model.go:620-641`) adds a 256-byte cap. The bidi and - zero-width sets are listed explicitly rather than rejecting all of category - Cf, because `U+200C` (ZWNJ) is required for Persian and Indic text and - `U+200D` (ZWJ) joins emoji sequences; a blanket ban would make legitimate - names unwritable. Covered by `deceptive_names_test.go`, including the - over-blocking cases. - - Severity was low and remains worth recording: `DisplayName` is never read - for a decision — four references in the tree, all declaration, validation, - or construction — and identity is keyed on ID, key ID, and public-key - fingerprint. Nothing was forgeable. The target was the human review step - that the audit and release stages depend on, via the Trojan Source technique - (CVE-2021-42574) applied to attested names rather than source code. - -4. **Whitespace-only values passed presence checks in two attested fields — - FIXED 2026-08-13.** `ContributionEnvironment.OS`/`.Architecture` - (`attestation.go:145`) and audit findings (`chain.go:1038`) used plain - `== ""`, so `" "` satisfied "must not be empty." Both now require trimmed, - non-empty values, matching the `DisplayName` convention. +The deliberate security defenses in `internal/mpcceremony` and its CLI, each +mapped to the attack it counters, with code anchors (line numbers drift; treat +them as anchors, not guarantees). Known gaps at the end. Consumer-package +hardening (prover, wasm, streampk, proofassets) is tracked separately in the +"untrusted decode" PR. + +## The five ideas (ELI5) + +The ceremony is a group taking turns stirring secret ingredients into a shared +pot; the result is safe if one ingredient stays secret and nobody swaps the pot +unwatched. Almost every defense below is one of five ideas: + +1. **Never trust a label — check the contents.** Everything carries a hash, + recomputed at every use, not once. +2. **Never trust a path.** Look before opening, open, look again — symlinks and + mid-read swaps are caught. +3. **Write once, never overwrite.** History is append-only and hash-chained; + publishing is create-only-if-absent. +4. **One person can't cheat alone.** Distinct keys per role, multiple + sign-offs, randomness from a public beacon fixed in the future. +5. **Assume every input is hostile.** One canonical form, exact lengths, sane + bounds; two encodings of "the same" thing is an attack. + +## 1 · Filesystem + +- Symlink swap: `Lstat` + `ModeSymlink` rejection before every read + (`files.go` `openRegularExact`, `workflow.go` `readRegularBounded`, + publication/audit/decision walks); per-component parent check + (`rejectSymlinkComponents`). +- TOCTOU: `os.SameFile` after open, size stability during hash, trailing-byte + read after (`workflow.go`, `publication.go`, `keybundle`). +- Path traversal: clean-relative-name validation (`validateArtifactName`), + `filepath.Rel` containment (`resolveArtifactPath`), stdin/URL rejection at + the CLI. +- Overwrite/rollback: `O_EXCL`, hard-link publish, `RENAME_NOREPLACE`, + retry only against byte-identical existing state (`requireAbsentOrExact`); + signature published before its record; fsync with re-validating recovery. +- Permissions: 0600 files, 0700 dirs, group/world bits rejected; directory + member allowlists. +- Exhaustion: size caps everywhere (16 GiB artifacts, 16 MiB records, 1 MiB + drand, 4 KiB keys, 100k-entry trees). + +## 2 · Cryptographic + +- Forged records: Ed25519 over exact bytes before parsing; out-of-band + coordinator anchor; `KeyID` untrusted until the bytes authenticate + (`attestation.go` `VerifyExact`, `workflow.go` `LoadSignedDefinition`). +- Unusable identity keys: canonical-encoding and small-order rejection via + `filippo.io/edwards25519` (`validateEd25519PublicKey`) — a small-order key + verifies signatures for any message. +- Key substitution: fingerprint re-derived on load; private key must match the + enrolled identity. +- Artifact substitution: dual SHA-256+BLAKE2b+size pinning, re-hashed at every + use; R1CS digested before native decode; running binary digest-matched to + the signed definition on every command. +- Encoding equivalence: decoded gnark objects re-serialized and required + byte-identical (`requireCanonicalRoundTrip`). +- Invalid points: BLS12-381 compressed-flag check (`preflight.go`); gnark + subgroup checks on by default on the ceremony path. +- Context confusion: per-record-type domain tags + `0x00` separator; beacon + challenge uses length-prefixed tuple encoding; content-addressed record IDs + recomputed everywhere. +- Rigged beacon: drand quicknet chain/key/scheme pinned in the signed + definition; randomness derived from the verified BLS signature, never + operator-supplied. +- Broken verifier: finalization requires the verifier to *reject* seven + tampered variants (negative controls, `finalize.go`). +- Mutation aliasing: archived inputs cloned before gnark's mutating + `Verify`/`Seal` (`streamClone`; acceptance path verifies a throwaway clone); + spent seal heads not retained; panic boundaries around gnark decode/verify. + +## 3 · Serialization + +- Canonical JSON: duplicate/unknown-field and trailing-data rejection, then + re-marshal byte-equality (`UnmarshalCanonical`); depth/key caps + (`strictjson`). +- Length-field lies: exact-size `LimitedReader`, EOF proof, `math/bits` + overflow-checked arithmetic, allocation only after locally derived expected + sizes (`preflight.go` — Phase 2 shape never taken from an untrusted + artifact). +- Aliasing: lowercase exact-length hex; canonical RFC3339Nano timestamps. + +## 4 · Identity and roster + +- Sybil/role overlap: three-dimension uniqueness (ID, key ID, fingerprint) + across coordinator, release signer, auditors, roster, witnesses, mirrors; + release signer ≠ coordinator; external auditors disjoint from all actors. +- Deceptive names: control characters, bidi formatting, and zero-width + characters rejected in display names and artifact-name segments + (`rejectDeceptiveRunes` — explicit Cf list so ZWNJ/ZWJ stay writable); + 256-byte display-name cap; whitespace-only attested fields rejected. +- Bounds aligned across layers: auditors 2..20 at enrollment = transcript + capacity; IDs restricted to `[a-z0-9-_.:]`, 1..128. + +## 5 · Transcript and chain + +- History rewrite: hash-chained records (index, previous payload, previous + record ID), whole-chain validation on append, frozen scheduled participant + order, ≤20 records. +- Fake contributions: full replay from deterministic genesis with per-step + `Verify`; no-op contributions rejected; gnark challenge must equal SHA-256 + of the previous payload (binds native transcript to the JSON chain); + 10-field attestation binding plus chronology; erasure binds the contribution + and must postdate it. + +## 6 · Network + +- The package imports no networking; evidence URIs are validated + (`https`/`ipfs`, no userinfo/fragment) and recorded, never fetched. + +## 7 · Process and operations + +- Beacon precommitment: future-round requirement; round schedule pinned; lead + re-checked immediately before atomic publish; production reserves a witness + observation window on top of the signed minimum (`requiredCloseLead`) so + witness receipts stay satisfiable; derived rounds sampled from the + post-replay clock; Phase 2 round must differ from Phase 1. +- Quorums: witnesses ≥2 (distinct IDs and fingerprints, unanimous on closure), + 3–16 distinct-operator relay observations, 2–8 mirror receipts per head, + ≥2 audits. +- Production mode: clean git tree, pinned build profile, no module `replace`, + all scheduled participants required, running software re-verified per + command. +- Separation of duties: release needs ≥2 distinct passing audits and a + distinct pre-existing release key; GO needs coordinator + every named + auditor + release signer, exactly; audits bundled in auditor-ID order so + the transcript always matches the decision's required order. +- Recovery: read-only `inspect` reports chain state and the next scheduled + contribution from signed data only — no key, no writes, no replay. +- Release trees: exact name-set equality, no unpinned files, sorted checksum + manifests, ceilings derived from the bundle layers' own maxima (32768). + +## 8 · Other + +- CLI diagnostics redact argv by construction (single stderr outlet); short + values replaced only as whole tokens so short key IDs stay protected without + blanking unrelated digits. +- Secrets excluded from published evidence; fixed sidecar paths, no `latest` + discovery; golden public vector pinned. + +## Fixed during this audit + +Ed25519 point validation · deceptive-rune and display-name hardening · +whitespace-only attested fields · artifact-name control characters · +clone-before-verify on the acceptance path · witness observation window · +counted-gate alignment (auditor cap, audit ordering, release-tree ceiling) · +audits-gate label renamed while no signed record existed · redaction by +construction with token matching · read-only `inspect` · beacon round derived +post-replay · replay/seal/phase2-init progress reporting. + +## Known gaps (open) + +1. **`streampk` URL path has no digest verification.** `OpenKeyURL` range-reads + proving-key bytes into decoders with `NoSubgroupChecks()`; the compensating + `IsOnCurve` landed in the untrusted-decode PR, but nothing hashes the + fetched bytes against the signed manifest on that path. +2. **Mainnet has no script-hash recompile gate.** The exporter binds the VK + hash to the VK bytes, but nothing binds `reclaim_global.script_hash` to a + script recompiled from the VK outside the Preprod-pinned + `formal/scripts/lock-active-artifacts.mjs`. Fix belongs in + `ValidateReclaimDeployment` or by lifting the Preprod-only guard. +3. **Latent enrollment-cap overflow.** The bundle's per-category maxima + (witnesses, per-head mirror operators) sum past the 128-identity enrollment + cap; reachable only with genuinely distinct operators at every head. + Fails closed at bundle assembly. +4. **No constant-time comparisons in the package.** Defensible — every + comparison is over public values — recorded so reviewers don't re-derive it. From 411adc265c77636d1e7d7cf726307409e5a630c8 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Wed, 19 Aug 2026 16:03:46 +0000 Subject: [PATCH 23/42] fix untrusted decode preflight bypasses --- cmd/wasm-prover/main_js.go | 40 +++++++++++++----- cmd/wasm-prover/main_js_test.go | 37 +++++++++++++++++ internal/proofassets/pkindex.go | 19 ++++++--- internal/proofassets/pkindex_test.go | 11 +++++ internal/prover/constraint_system.go | 33 +++++++++++++++ internal/prover/constraint_system_test.go | 49 +++++++++++++++++++++++ internal/prover/prover.go | 35 ++++++++++++++++ internal/prover/prover_test.go | 22 ++++++++++ 8 files changed, 231 insertions(+), 15 deletions(-) create mode 100644 cmd/wasm-prover/main_js_test.go create mode 100644 internal/prover/constraint_system.go create mode 100644 internal/prover/constraint_system_test.go diff --git a/cmd/wasm-prover/main_js.go b/cmd/wasm-prover/main_js.go index 2f6502f..ccee977 100644 --- a/cmd/wasm-prover/main_js.go +++ b/cmd/wasm-prover/main_js.go @@ -12,6 +12,7 @@ import ( "fmt" "hash" "io" + "math" "net/http" "net/url" "os" @@ -1078,9 +1079,19 @@ func zstdMaxMemory(maxDecoded int64) uint64 { return uint64(maxDecoded) } -// safeCCSReadFrom decodes a constraint system, converting a decoder panic -// (e.g. make([]byte, totalLen) on a hostile length prefix) into an error so a -// malformed object cannot abort the wasm module. +func boundedCompressedWire(r io.Reader, size int64) (io.Reader, error) { + if r == nil { + return nil, fmt.Errorf("compressed ccs reader is required") + } + if size <= 0 || size == math.MaxInt64 { + return nil, fmt.Errorf("compressed ccs size %d cannot be bounded", size) + } + return io.LimitReader(r, size+1), nil +} + +// safeCCSReadFrom converts ordinary decoder panics into errors. Allocation +// safety does not rely on recover: PreflightConstraintSystemReader rejects the +// declared payload length before gnark can allocate from it. func safeCCSReadFrom(ccs constraint.ConstraintSystem, r io.Reader) (err error) { defer func() { if rec := recover(); rec != nil { @@ -1182,7 +1193,14 @@ func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin, maxDeco if err != nil { return nil, prover.FileDigest{}, fmt.Errorf("create blake2b digest: %w", err) } - wire = &countingReader{r: io.TeeReader(body, io.MultiWriter(wireSHA, wireBlake))} + // The signed compressed size is known before transport. Read at most one + // byte beyond it so an oversized or endless response fails without being + // drained to EOF first. + boundedWire, err := boundedCompressedWire(body, compressed.Size) + if err != nil { + return nil, prover.FileDigest{}, err + } + wire = &countingReader{r: io.TeeReader(boundedWire, io.MultiWriter(wireSHA, wireBlake))} // Bound the decoder's window memory. klauspost's default is 64 GiB, so // without this a tiny frame declaring a huge window is itself a memory // bomb, independent of how much output we read. @@ -1196,11 +1214,9 @@ func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin, maxDeco decoded = body } // Cap the decoded byte count at the pinned size (plus one, to detect - // overrun). gnark's CS decoder trusts an 8-byte length prefix and does - // make([]byte, totalLen) before reading; the limit stops an inflate bomb - // or a corrupt object from streaming unbounded bytes, and the recover - // boundary below turns an oversized make into an error instead of aborting - // the wasm module. + // overrun). Preflight the fixed gnark header below as well: LimitReader caps + // transport, but gnark allocates from its declared length before reading the + // payload. if maxDecoded < 1 { maxDecoded = maxCCSDecodedBytes } @@ -1210,7 +1226,11 @@ func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin, maxDeco ccs := groth16.NewCS(ecc.BLS12_381) decodeStarted := time.Now() bodyBefore, hashBefore := body.duration, hashes.duration - if err := safeCCSReadFrom(ccs, reader); err != nil { + ccsReader, err := prover.PreflightConstraintSystemReader(reader, maxDecoded) + if err == nil { + err = safeCCSReadFrom(ccs, ccsReader) + } + if err != nil { err = fmt.Errorf("read constraint system: %w", err) if compressed != nil { // A truncated frame or mid-body reset on the compressed object is diff --git a/cmd/wasm-prover/main_js_test.go b/cmd/wasm-prover/main_js_test.go new file mode 100644 index 0000000..9a40c83 --- /dev/null +++ b/cmd/wasm-prover/main_js_test.go @@ -0,0 +1,37 @@ +//go:build js && wasm + +package main + +import ( + "bytes" + "io" + "math" + "strings" + "testing" +) + +func TestBoundedCompressedWireAllowsOneByteForOverrunDetection(t *testing.T) { + source := bytes.NewReader([]byte("0123456789")) + r, err := boundedCompressedWire(source, 4) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if string(got) != "01234" { + t.Fatalf("bounded bytes = %q, want %q", got, "01234") + } + if source.Len() != 5 { + t.Fatalf("bounded reader consumed %d bytes past its cap", 5-source.Len()) + } +} + +func TestBoundedCompressedWireRejectsUnsafeSizes(t *testing.T) { + for _, size := range []int64{0, -1, math.MaxInt64} { + if _, err := boundedCompressedWire(strings.NewReader("x"), size); err == nil { + t.Fatalf("size %d was accepted", size) + } + } +} diff --git a/internal/proofassets/pkindex.go b/internal/proofassets/pkindex.go index cf000f5..83a9082 100644 --- a/internal/proofassets/pkindex.go +++ b/internal/proofassets/pkindex.go @@ -158,7 +158,9 @@ func ValidatePKIndex(idx *PKIndex) error { if sec.Len%int64(sec.ElemSize) != 0 { return fmt.Errorf("section %q length %d is not divisible by elem_size %d", name, sec.Len, sec.ElemSize) } - if sec.Offset+sec.Len > idx.FileSize { + // Subtraction keeps a hostile offset+length pair from wrapping int64 + // negative and passing the file boundary check. + if sec.Len > idx.FileSize || sec.Offset > idx.FileSize-sec.Len { return fmt.Errorf("section %q exceeds file size", name) } } @@ -188,12 +190,19 @@ func ValidatePKIndexAllocations(idx *PKIndex) error { // two infinity bitmaps of NbWires bytes each, then the 4-byte commitment // count. Everything must fit inside FileSize. const infHeaderLen = 3 * 8 - infOff := g2b.Offset + g2b.Len - if idx.NbWires > math.MaxInt64/2 { + const countLen = 4 + if idx.NbWires > math.MaxInt64 { return fmt.Errorf("nb_wires %d is implausibly large", idx.NbWires) } - bitmapEnd := infOff + infHeaderLen + 2*int64(idx.NbWires) - if bitmapEnd+4 > idx.FileSize { + if g2b.Len > idx.FileSize || g2b.Offset > idx.FileSize-g2b.Len { + return fmt.Errorf("G2B section exceeds file_size %d", idx.FileSize) + } + infOff := g2b.Offset + g2b.Len + if infOff > idx.FileSize || idx.FileSize-infOff < infHeaderLen+countLen { + return fmt.Errorf("infinity metadata does not fit within file_size %d", idx.FileSize) + } + bitmapBytes := idx.FileSize - infOff - infHeaderLen - countLen + if idx.NbWires > uint64(bitmapBytes/2) { return fmt.Errorf("nb_wires %d does not fit within file_size %d", idx.NbWires, idx.FileSize) } if idx.NbInfinityA > idx.NbWires || idx.NbInfinityB > idx.NbWires { diff --git a/internal/proofassets/pkindex_test.go b/internal/proofassets/pkindex_test.go index bbc0a66..2122fcf 100644 --- a/internal/proofassets/pkindex_test.go +++ b/internal/proofassets/pkindex_test.go @@ -44,6 +44,7 @@ func TestValidatePKIndexAllocations(t *testing.T) { }{ {"huge commitment count", func(i *PKIndex) { i.NbCommitmentKeys = 0xFFFFFFFF }, "nb_commitment_keys"}, {"nbWires overflow", func(i *PKIndex) { i.NbWires = math.MaxUint64 }, "implausibly large"}, + {"nbWires arithmetic boundary", func(i *PKIndex) { i.NbWires = math.MaxInt64 / 2 }, "does not fit"}, {"nbWires exceeds file", func(i *PKIndex) { i.NbWires = 1 << 40 }, "does not fit"}, {"infinity exceeds wires", func(i *PKIndex) { i.NbInfinityA = 5 }, "exceeds nb_wires"}, {"missing basis section", func(i *PKIndex) { @@ -64,3 +65,13 @@ func TestValidatePKIndexAllocations(t *testing.T) { }) } } + +func TestValidatePKIndexRejectsSectionEndOverflow(t *testing.T) { + idx := validAllocIndex() + section := idx.Sections["G2B"] + section.Offset = math.MaxInt64 - section.Len + 1 + idx.Sections["G2B"] = section + if err := ValidatePKIndex(idx); err == nil || !strings.Contains(err.Error(), "exceeds file size") { + t.Fatalf("expected overflowing section rejection, got %v", err) + } +} diff --git a/internal/prover/constraint_system.go b/internal/prover/constraint_system.go new file mode 100644 index 0000000..d8f6a79 --- /dev/null +++ b/internal/prover/constraint_system.go @@ -0,0 +1,33 @@ +package prover + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" +) + +const gnarkConstraintSystemHeaderBytes = 4 * 8 + +// PreflightConstraintSystemReader validates gnark's declared payload length +// before ReadFrom can allocate from it. The returned reader replays the header +// and then continues from r, so callers can pass it directly to ReadFrom. +// Callers must still cap r itself to maxBytes to bound the bytes transported. +func PreflightConstraintSystemReader(r io.Reader, maxBytes int64) (io.Reader, error) { + if r == nil { + return nil, fmt.Errorf("constraint system reader is required") + } + if maxBytes < gnarkConstraintSystemHeaderBytes { + return nil, fmt.Errorf("constraint system maximum %d is smaller than its %d-byte header", maxBytes, gnarkConstraintSystemHeaderBytes) + } + var header [gnarkConstraintSystemHeaderBytes]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("read constraint system header: %w", err) + } + declared := binary.LittleEndian.Uint64(header[:8]) + maxPayload := uint64(maxBytes - gnarkConstraintSystemHeaderBytes) + if declared > maxPayload { + return nil, fmt.Errorf("constraint system declares %d payload bytes, exceeds maximum %d", declared, maxPayload) + } + return io.MultiReader(bytes.NewReader(header[:]), r), nil +} diff --git a/internal/prover/constraint_system_test.go b/internal/prover/constraint_system_test.go new file mode 100644 index 0000000..7e7019e --- /dev/null +++ b/internal/prover/constraint_system_test.go @@ -0,0 +1,49 @@ +package prover + +import ( + "bytes" + "encoding/binary" + "io" + "strings" + "testing" +) + +func TestPreflightConstraintSystemReader(t *testing.T) { + header := make([]byte, gnarkConstraintSystemHeaderBytes) + binary.LittleEndian.PutUint64(header[:8], 3) + raw := append(header, 1, 2, 3) + + r, err := PreflightConstraintSystemReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("replayed bytes differ: got %x, want %x", got, raw) + } +} + +func TestPreflightConstraintSystemReaderRejectsDeclaredAllocation(t *testing.T) { + header := make([]byte, gnarkConstraintSystemHeaderBytes) + binary.LittleEndian.PutUint64(header[:8], 1<<30) + body := []byte("body must remain unread") + source := bytes.NewReader(append(header, body...)) + + _, err := PreflightConstraintSystemReader(source, 1024) + if err == nil || !strings.Contains(err.Error(), "declares") { + t.Fatalf("expected declared-size rejection, got %v", err) + } + if source.Len() != len(body) { + t.Fatalf("preflight read %d payload bytes", len(body)-source.Len()) + } +} + +func TestPreflightConstraintSystemReaderRejectsShortHeader(t *testing.T) { + _, err := PreflightConstraintSystemReader(bytes.NewReader(make([]byte, 8)), 1024) + if err == nil || !strings.Contains(err.Error(), "header") { + t.Fatalf("expected short-header rejection, got %v", err) + } +} diff --git a/internal/prover/prover.go b/internal/prover/prover.go index 10cc397..1b33a51 100644 --- a/internal/prover/prover.go +++ b/internal/prover/prover.go @@ -75,6 +75,22 @@ const ( maxEncodedProofBytes = 4096 ) +func requireCompressedPoint(raw []byte, offset int, name string) error { + if offset < 0 || offset >= len(raw) { + return fmt.Errorf("proof is too short for %s", name) + } + // gnark accepts compressed and uncompressed encodings. The fixed offsets + // below are safe only for the canonical compressed representation emitted + // by MarshalProof. Accept the two compressed sign encodings and compressed + // infinity; reject every uncompressed or reserved metadata mask. + switch raw[offset] & 0xe0 { + case 0x80, 0xa0, 0xc0: + return nil + default: + return fmt.Errorf("proof %s must use canonical compressed encoding", name) + } +} + type OwnershipBundle struct { Dir string Manifest *artifact.KeyManifest @@ -453,6 +469,15 @@ func UnmarshalProof(encoded string) (groth16.Proof, error) { if len(raw) < proofCommitmentCountOffset+4 { return nil, fmt.Errorf("proof is %d bytes, too short to be well-formed", len(raw)) } + if err := requireCompressedPoint(raw, 0, "Ar"); err != nil { + return nil, err + } + if err := requireCompressedPoint(raw, g1Len, "Bs"); err != nil { + return nil, err + } + if err := requireCompressedPoint(raw, g1Len+g2Len, "Krs"); err != nil { + return nil, err + } nbCommitments := binary.BigEndian.Uint32(raw[proofCommitmentCountOffset : proofCommitmentCountOffset+4]) if nbCommitments > maxProofCommitments { return nil, fmt.Errorf("proof declares %d commitments, exceeds maximum %d", nbCommitments, maxProofCommitments) @@ -463,6 +488,16 @@ func UnmarshalProof(encoded string) (groth16.Proof, error) { if len(raw) != wantLen { return nil, fmt.Errorf("proof is %d bytes, want %d for %d commitments", len(raw), wantLen, nbCommitments) } + pointOffset := proofCommitmentCountOffset + 4 + for i := uint32(0); i < nbCommitments; i++ { + if err := requireCompressedPoint(raw, pointOffset, fmt.Sprintf("commitment[%d]", i)); err != nil { + return nil, err + } + pointOffset += g1Len + } + if err := requireCompressedPoint(raw, pointOffset, "commitment proof"); err != nil { + return nil, err + } proof := groth16.NewProof(curve) if _, err := proof.ReadFrom(bytes.NewReader(raw)); err != nil { return nil, fmt.Errorf("read proof: %w", err) diff --git a/internal/prover/prover_test.go b/internal/prover/prover_test.go index e4bf4f4..2798f48 100644 --- a/internal/prover/prover_test.go +++ b/internal/prover/prover_test.go @@ -63,6 +63,9 @@ func TestUnmarshalProofRejectsHostileCommitmentCount(t *testing.T) { // primitive for any endpoint that decodes untrusted proofs. It must be // rejected before ReadFrom is ever called. raw := make([]byte, proofCommitmentCountOffset+4) + raw[0] = 0xc0 + raw[g1Len] = 0xc0 + raw[g1Len+g2Len] = 0xc0 binary.BigEndian.PutUint32(raw[proofCommitmentCountOffset:], 0xFFFFFFFF) if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(raw)); err == nil || !strings.Contains(err.Error(), "commitments") { @@ -84,6 +87,9 @@ func TestUnmarshalProofRejectsHostileCommitmentCount(t *testing.T) { // Declared count is in range but the body length does not match it. mismatch := make([]byte, proofCommitmentCountOffset+4) + mismatch[0] = 0xc0 + mismatch[g1Len] = 0xc0 + mismatch[g1Len+g2Len] = 0xc0 binary.BigEndian.PutUint32(mismatch[proofCommitmentCountOffset:], 1) if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(mismatch)); err == nil || !strings.Contains(err.Error(), "want") { @@ -91,6 +97,22 @@ func TestUnmarshalProofRejectsHostileCommitmentCount(t *testing.T) { } } +func TestUnmarshalProofRejectsShiftedCommitmentCount(t *testing.T) { + // Ar and Bs are compressed infinity, while Krs is uncompressed infinity. + // The old fixed-offset preflight read zero halfway through Krs, then gnark + // reached the actual count after the 96-byte Krs and allocated from it. + raw := make([]byte, proofCommitmentCountOffset+4+g1Len) + raw[0] = 0xc0 + raw[g1Len] = 0xc0 + raw[g1Len+g2Len] = 0x40 + binary.BigEndian.PutUint32(raw[len(raw)-4:], maxProofCommitments+1) + + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(raw)); err == nil || + !strings.Contains(err.Error(), "Krs must use canonical compressed encoding") { + t.Fatalf("expected non-canonical Krs rejection, got %v", err) + } +} + func TestOwnershipProofRoundTripIntegration(t *testing.T) { if os.Getenv("PROOF_TOOL_RUN_FULL_PROOF") != "1" { t.Skip("set PROOF_TOOL_RUN_FULL_PROOF=1 to run the full ownership Groth16 proof") From 1aca232bce4a9e188fd89d0400e1048491f68b61 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Wed, 19 Aug 2026 16:26:40 +0000 Subject: [PATCH 24/42] add MPC ceremony release gates --- .../mpc-ceremony-release-validation.yml | 120 +++++++++++++ docs/mpc-ceremony-local-runbook.md | 4 +- docs/mpc-ceremony-release.md | 170 ++++++++++++++++++ 3 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/mpc-ceremony-release-validation.yml create mode 100644 docs/mpc-ceremony-release.md diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml new file mode 100644 index 0000000..974f347 --- /dev/null +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -0,0 +1,120 @@ +name: MPC ceremony release validation + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mpc-ceremony-release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +env: + # Update this only after reviewing the Relay change and rerunning this gate. + RELAY_COMMIT: c0ccd19f884d6cb355372be95dd159405c3bf368 + +jobs: + rehearsal-reproducibility: + name: Reproducible unsigned rehearsal + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + - name: Verify release inputs + shell: bash + run: | + test "$(go env GOVERSION)" = go1.26.5 + test "$(go env GOHOSTOS)" = linux + test "$(go env GOHOSTARCH)" = amd64 + test "$(sed -n 's/^module //p' go.mod)" = proof-tool + + - name: Bootstrap patched vendor tree + run: bash scripts/bootstrap-vendor.sh + + - name: Build two unsigned rehearsals + shell: bash + run: | + mkdir "$RUNNER_TEMP/mpc-rehearsal-a-parent" + mkdir "$RUNNER_TEMP/mpc-rehearsal-b-parent" + scripts/build-mpc-ceremony-release.sh \ + --mode rehearsal \ + --out-dir "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" + scripts/build-mpc-ceremony-release.sh \ + --mode rehearsal \ + --out-dir "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + + - name: Verify byte-for-byte reproducibility + shell: bash + run: | + scripts/verify-mpc-ceremony-reproducible.sh \ + --mode rehearsal \ + --expected-commit "$GITHUB_SHA" \ + --expected-tag none \ + --tag-signer-fingerprint none \ + --trusted-build-public-key-file none \ + "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" \ + "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + + - name: Confirm rehearsal packages are unsigned + shell: bash + run: | + for release in \ + "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" \ + "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + do + test "$(<"$release/build-mode.txt")" = rehearsal + test "$(<"$release/signed-tag.txt")" = none + test "$(<"$release/signed-tag-status.txt")" = not-required-for-rehearsal + test ! -e "$release/build-package-manifest.sig" + test ! -e "$release/build-package-manifest-public-key.hex" + done + + relay-compatibility: + name: Relay CLI compatibility (pinned commit) + needs: rehearsal-reproducibility + runs-on: ubuntu-latest + timeout-minutes: 20 + steps: + - name: Check out proof-tool + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + path: proof-tool + fetch-depth: 0 + persist-credentials: false + + - name: Check out pinned Relay + uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + repository: zksecurity/relay + ref: ${{ env.RELAY_COMMIT }} + path: relay + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: proof-tool/go.mod + cache: false + + - name: Exercise the proof-tool CLI boundary + shell: bash + run: | + test "$(git -C relay rev-parse HEAD)" = "$RELAY_COMMIT" + cd relay + RELAY_PROOF_TOOL_DIR="$GITHUB_WORKSPACE/proof-tool" \ + go test ./cmd/relay \ + -run '^TestProofToolCompatibility$' \ + -count=1 \ + -v diff --git a/docs/mpc-ceremony-local-runbook.md b/docs/mpc-ceremony-local-runbook.md index cee39c2..9cfebd6 100644 --- a/docs/mpc-ceremony-local-runbook.md +++ b/docs/mpc-ceremony-local-runbook.md @@ -33,7 +33,9 @@ source commit, and dependency versions; `VerifyRunningSoftware` refuses to proceed on a mismatch. So the binary is a trust input too: built from a verified signed tag, reproduced in two independent environments, hashes published separately. `scripts/build-mpc-ceremony-release.sh` and -`scripts/verify-mpc-ceremony-reproducible.sh` do this for production. +`scripts/verify-mpc-ceremony-reproducible.sh` do this for production. Maintainers +publish the directly downloadable binary and its full verification package by +following `docs/mpc-ceremony-release.md`. Everything else — `ceremony.json`, `ceremony.sig`, chains, contributions, closures — may travel over untrusted transport. Tampering makes verification diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md new file mode 100644 index 0000000..353f7ec --- /dev/null +++ b/docs/mpc-ceremony-release.md @@ -0,0 +1,170 @@ +# Publishing `mpc-ceremony` + +This is the maintainer procedure for publishing the Linux/amd64 +`mpc-ceremony` binary and its complete verification package. Ceremony operators +normally download and verify these assets; they do not need the release build +environment or its private signing key. + +The Go module remains `proof-tool`. Relay communicates with `mpc-ceremony` +through its versioned CLI output, so publishing does not require a module-path +migration or an importable Go package. + +## Release assets + +Every GitHub release must contain both: + +- `mpc-ceremony`, the directly downloadable Linux/amd64 executable; and +- `mpc-ceremony--linux-amd64.tar`, the complete directory produced by + `scripts/build-mpc-ceremony-release.sh`, including checksums, SBOMs, source + and toolchain metadata, and the package manifest. + +Publishing `checksums.sha256` separately is recommended for convenience. The +authenticated release announcement must independently state the repository, +tag, source commit, binary SHA-256, package SHA-256, release mode, and—only for +production—the approved tag-signer and build-signing public-key fingerprints. +A checksum hosted beside a binary detects transfer corruption but is not an +independent trust channel. + +## Required release gates + +Before selecting a release commit: + +1. Merge all approved security fixes. +2. Require the `MPC ceremony release validation` workflow to pass. It rebuilds + the patched vendor tree, creates two unsigned rehearsal packages, verifies + that they are byte-identical, confirms that no production signatures exist, + and exercises the CLI against the exact Relay commit pinned in the workflow. +3. Review any change to `RELAY_COMMIT`; a moving branch or tag is not an + acceptable compatibility input. +4. Confirm `go.mod` still declares `module proof-tool`. + +The workflow can also be rerun from the Actions tab with **Run workflow**. CI +rehearsals are unsigned and are never production releases. + +## Publish a test release + +A test release proves the download path without using either production signing +key. Its tag and GitHub release must say `rehearsal`, and the release must be a +prerelease. + +Start from a clean ordinary clone, not a linked worktree, so Go can embed the +exact VCS revision: + + TEST_TAG=mpc-ceremony-rehearsal-v0.0.0-YYYYMMDD.N + git fetch origin --tags + git checkout --detach origin/main + test -z "$(git status --porcelain)" + test "$(sed -n 's/^module //p' go.mod)" = proof-tool + bash scripts/bootstrap-vendor.sh + mkdir -p /tmp/mpc-release-a-parent /tmp/mpc-release-b-parent + scripts/build-mpc-ceremony-release.sh \ + --mode rehearsal \ + --out-dir /tmp/mpc-release-a-parent/release + scripts/build-mpc-ceremony-release.sh \ + --mode rehearsal \ + --out-dir /tmp/mpc-release-b-parent/release + RELEASE_COMMIT=$(git rev-parse HEAD) + scripts/verify-mpc-ceremony-reproducible.sh \ + --mode rehearsal \ + --expected-commit "$RELEASE_COMMIT" \ + --expected-tag none \ + --tag-signer-fingerprint none \ + --trusted-build-public-key-file none \ + /tmp/mpc-release-a-parent/release \ + /tmp/mpc-release-b-parent/release + test ! -e /tmp/mpc-release-a-parent/release/build-package-manifest.sig + test ! -e /tmp/mpc-release-a-parent/release/build-package-manifest-public-key.hex + +Create a deterministic full-package archive and its separate checksums: + + RELEASE_DIR=/tmp/mpc-release-a-parent/release + RELEASE_EPOCH=$(<"$RELEASE_DIR/source-date-epoch.txt") + PACKAGE=/tmp/mpc-ceremony-$TEST_TAG-linux-amd64.tar + tar --sort=name --format=gnu --owner=0 --group=0 --numeric-owner \ + --mtime="@$RELEASE_EPOCH" \ + -C "$(dirname "$RELEASE_DIR")" \ + -cf "$PACKAGE" "$(basename "$RELEASE_DIR")" + cp "$RELEASE_DIR/checksums.sha256" /tmp/checksums.sha256 + (cd /tmp && sha256sum "$(basename "$PACKAGE")" > package.sha256) + +Tag the exact tested commit, push the tag, and create an explicitly unsigned +prerelease: + + git tag -a "$TEST_TAG" "$RELEASE_COMMIT" \ + -m "Unsigned mpc-ceremony rehearsal $TEST_TAG" + git push origin "refs/tags/$TEST_TAG" + gh release create "$TEST_TAG" \ + --repo zksecurity/proof-tool \ + --verify-tag \ + --prerelease \ + --title "UNSIGNED rehearsal: $TEST_TAG" \ + --notes "Unsigned test release for installation and compatibility testing. NOT FOR PRODUCTION CEREMONIES." \ + "$RELEASE_DIR/mpc-ceremony#mpc-ceremony (Linux amd64, unsigned rehearsal)" \ + "$PACKAGE#Complete unsigned verification package" \ + "/tmp/checksums.sha256#Binary checksums from the package" \ + "/tmp/package.sha256#Verification-package checksum" + +Download the assets into a fresh directory and compare them with the retained +local outputs before announcing the test: + + DOWNLOAD_DIR=$(mktemp -d /tmp/mpc-release-download.XXXXXXXX) + gh release download "$TEST_TAG" \ + --repo zksecurity/proof-tool \ + --dir "$DOWNLOAD_DIR" + sha256sum "$DOWNLOAD_DIR"/* + cmp "$DOWNLOAD_DIR/mpc-ceremony" "$RELEASE_DIR/mpc-ceremony" + +## Publish a production release + +Production is different in three ways: the source tag is signed by the approved +tag signer, the package manifest is signed by the offline release build key, +and an independent auditor reproduces and verifies the package before anything +is published. Never place the build-signing private key in GitHub Actions. + +On the offline Linux/amd64 release machine with Go 1.26.5, check out the approved +signed tag, bootstrap the vendor tree, and create two production builds: + + RELEASE_TAG=REPLACE_WITH_APPROVED_SIGNED_TAG + TAG_SIGNER_FINGERPRINT=REPLACE_WITH_APPROVED_FINGERPRINT + BUILD_SIGNING_KEY=/offline/mpc-build-signing-key + git fetch origin --tags + git checkout --detach "$RELEASE_TAG" + RELEASE_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}") + test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" + test -z "$(git status --porcelain)" + bash scripts/bootstrap-vendor.sh + mkdir -p /retained/mpc-release-a-parent /retained/mpc-release-b-parent + scripts/build-mpc-ceremony-release.sh \ + --mode production \ + --signed-tag "$RELEASE_TAG" \ + --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ + --build-signing-key "$BUILD_SIGNING_KEY" \ + --out-dir /retained/mpc-release-a-parent/release + scripts/build-mpc-ceremony-release.sh \ + --mode production \ + --signed-tag "$RELEASE_TAG" \ + --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ + --build-signing-key "$BUILD_SIGNING_KEY" \ + --out-dir /retained/mpc-release-b-parent/release + +The independent auditor obtains the build public key through the independent +trust channel and runs: + + TRUSTED_BUILD_PUBLIC_KEY=/trusted/mpc-build-public-key.hex + scripts/verify-mpc-ceremony-reproducible.sh \ + --mode production \ + --expected-commit "$RELEASE_COMMIT" \ + --expected-tag "$RELEASE_TAG" \ + --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ + --trusted-build-public-key-file "$TRUSTED_BUILD_PUBLIC_KEY" \ + /retained/mpc-release-a-parent/release \ + /retained/mpc-release-b-parent/release + +Package the verified `release` directory with the deterministic `tar` command +from the test procedure, replacing `TEST_TAG` with `RELEASE_TAG`. Upload the +direct binary, full package, and separate checksums with `gh release create`, +but omit `--prerelease` and all rehearsal wording. Publish the authenticated +release announcement only after an independent download-and-verify pass. + +Never reuse a test tag or replace assets on an existing release. If anything is +wrong, leave an audit trail, mark the release unusable, and publish a new tag. From 528f5566eeba970b35da48cd7706931502ba8ed9 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Wed, 19 Aug 2026 16:32:34 +0000 Subject: [PATCH 25/42] remove ineffectual replay assignments --- internal/mpcceremony/workflow.go | 6 ------ 1 file changed, 6 deletions(-) diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index d22ef33..5159eea 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -1960,9 +1960,6 @@ func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResu challenge, replayedHead, ) - // Seal spends the head and the returned commons aliases its backing - // arrays. Drop the reference here so a later reuse cannot compile. - replayedHead = nil if err != nil { return result, err } @@ -3244,9 +3241,6 @@ func loadPhase1CommonsForPhase2( challenge, replayedHead, ) - // Seal spends the head and the returned commons aliases its backing - // arrays. Drop the reference here so a later reuse cannot compile. - replayedHead = nil if err != nil { return nil, SealRecord{}, CloseRecord{}, fmt.Errorf( "derive Phase 1 commons from authenticated chain and beacon: %w", From 14c449aae41cc53bb6c5051c1e1f98bc55460e6d Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 02:01:08 +0000 Subject: [PATCH 26/42] Wait for the auto-install before driving the developer controls Both developer-control tests click "Install local proof assets" after waiting only for the setup heading. The one-shot auto-install added alongside them sets busy="install" as soon as the app finds proof assets missing, and that disables the button: disabled={busy === "install" || bundleSourceDir.trim() === "" || ...} fireEvent.click on a disabled button is silently dropped, so activateKeyBundle is never called and the test fails with "expected spy to be called once, but got 0 times". Whether the auto-install resolves before or after the click decides the outcome, so the tests pass locally and fail on a loaded runner. Observed on run 32276515240; both tests are affected, not just the one that happened to lose. Fill the fields first, then wait for the button to become enabled, then click. The order matters: the same guard placed before the fields are filled can never pass, because empty fields disable the button too. Verified by making the fake installProofAssetsRelease resolve after 25ms instead of in a microtask, which reproduces the CI failure exactly on both tests; with the guard in place all 13 pass, with and without the delay. --- apps/proof-helper-desktop/src/App.test.tsx | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/apps/proof-helper-desktop/src/App.test.tsx b/apps/proof-helper-desktop/src/App.test.tsx index 0347f76..fc79645 100644 --- a/apps/proof-helper-desktop/src/App.test.tsx +++ b/apps/proof-helper-desktop/src/App.test.tsx @@ -170,7 +170,11 @@ describe("Proof Helper desktop app", () => { fireEvent.change(screen.getByLabelText("Bundle source"), { target: { value: "/tmp/source-bundle" } }); fireEvent.change(screen.getByLabelText("Manifest public key"), { target: { value: "ab".repeat(32) } }); fireEvent.change(screen.getByLabelText("Signature key id"), { target: { value: "test-signer" } }); - fireEvent.click(screen.getByRole("button", { name: /install local proof assets/i })); + // The one-shot auto-install sets busy="install", which disables this button. + // Without waiting, the click is dropped and activateKeyBundle is never called. + const install = screen.getByRole("button", { name: /install local proof assets/i }); + await waitFor(() => expect(install).toBeEnabled()); + fireEvent.click(install); await waitFor(() => expect(api.activateKeyBundle).toHaveBeenCalledOnce()); expect(api.activateKeyBundle).toHaveBeenCalledWith({ @@ -191,7 +195,9 @@ describe("Proof Helper desktop app", () => { await screen.findByRole("heading", { name: "Proof assets need setup" }); fireEvent.change(screen.getByLabelText("Bundle source"), { target: { value: "/tmp/source-bundle" } }); fireEvent.change(screen.getByLabelText("Manifest public key"), { target: { value: "cd".repeat(32) } }); - fireEvent.click(screen.getByRole("button", { name: /install local proof assets/i })); + const install = screen.getByRole("button", { name: /install local proof assets/i }); + await waitFor(() => expect(install).toBeEnabled()); + fireEvent.click(install); const cancel = await screen.findAllByRole("button", { name: /cancel install/i }); fireEvent.click(cancel[0]); From c1fd3e6856a46a77758447c1a67a64b8e074a8ff Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 07:47:20 +0000 Subject: [PATCH 27/42] add downloadable tiny ceremony initializer --- .../mpc-ceremony-release-validation.yml | 41 ++- cmd/mpc-ceremony/cli_test.go | 6 + cmd/mpc-ceremony/executor.go | 24 +- cmd/mpc-ceremony/integration_test.go | 2 + cmd/mpc-ceremony/main.go | 6 +- cmd/mpc-ceremony/parse.go | 56 +++- cmd/mpc-ceremony/rehearsal.go | 63 +++++ cmd/mpc-ceremony/rehearsal_test.go | 62 +++++ cmd/mpc-ceremony/types.go | 6 + cmd/mpc-ceremony/usage.go | 20 +- internal/circuit/rehearsal/circuit.go | 73 ++++++ internal/mpcceremony/definition.go | 14 + internal/mpcceremony/model.go | 27 +- internal/mpcceremony/r1cs.go | 106 +++++++- .../mpcceremony/rehearsal_circuit_test.go | 127 +++++++++ internal/mpcrehearsal/config.go | 243 ++++++++++++++++++ scripts/mpc-rehearsal-config/main.go | 233 +---------------- 17 files changed, 855 insertions(+), 254 deletions(-) create mode 100644 cmd/mpc-ceremony/rehearsal.go create mode 100644 cmd/mpc-ceremony/rehearsal_test.go create mode 100644 internal/circuit/rehearsal/circuit.go create mode 100644 internal/mpcceremony/rehearsal_circuit_test.go create mode 100644 internal/mpcrehearsal/config.go diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml index 974f347..d0d0e70 100644 --- a/.github/workflows/mpc-ceremony-release-validation.yml +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -15,7 +15,7 @@ concurrency: env: # Update this only after reviewing the Relay change and rerunning this gate. - RELAY_COMMIT: c0ccd19f884d6cb355372be95dd159405c3bf368 + RELAY_COMMIT: f4e8a560e2cdae49618b76ef655bfed76bb65e26 jobs: rehearsal-reproducibility: @@ -68,6 +68,45 @@ jobs: "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" \ "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + - name: Exercise download-only tiny rehearsal initialization + shell: bash + run: | + set -euo pipefail + ceremony_binary="$RUNNER_TEMP/mpc-rehearsal-a-parent/release/mpc-ceremony" + rehearsal_root="$RUNNER_TEMP/downloadable-tiny-rehearsal" + "$ceremony_binary" rehearsal init \ + --created-at 2026-08-20T06:00:00Z \ + --out-dir "$rehearsal_root" + "$ceremony_binary" --format json inspect definition \ + --ceremony "$rehearsal_root/public/ceremony.json" \ + --ceremony-signature "$rehearsal_root/public/ceremony.sig" \ + --coordinator-public-key-file \ + "$rehearsal_root/public/coordinator-public-key.hex" \ + >"$RUNNER_TEMP/downloadable-tiny-definition.json" + python3 - "$RUNNER_TEMP/downloadable-tiny-definition.json" <<'PY' + import json + import sys + + with open(sys.argv[1], "rb") as handle: + result = json.load(handle) + inspection = result["definition_inspection"] + assert result["ok"] is True + assert inspection["mode"] == "rehearsal" + assert inspection["phase1_participants"] == [ + "participant-01", "participant-02", "participant-03" + ] + PY + test -f "$rehearsal_root/config/environment.json" + test -f "$rehearsal_root/keys/coordinator.ed25519.private.hex" + test "$(stat -c %a "$rehearsal_root/keys/coordinator.ed25519.private.hex")" = 600 + if "$ceremony_binary" rehearsal init \ + --created-at 2026-08-20T06:00:01Z \ + --out-dir "$rehearsal_root"; then + echo "rehearsal initializer overwrote an existing root" >&2 + exit 1 + fi + test -f "$rehearsal_root/public/ceremony.json" + - name: Confirm rehearsal packages are unsigned shell: bash run: | diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 0f30b82..3239e91 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -951,6 +951,12 @@ func TestDiagnosticRedactionRecognizesInspectionAndReceiptCommands(t *testing.T) commandIndex: 0, valueIndex: 3, }, + { + name: "rehearsal initializer", + args: []string{"rehearsal", "init", "--out-dir", "private-rehearsal"}, + commandIndex: 0, + valueIndex: 3, + }, } { t.Run(test.name, func(t *testing.T) { safe := identifyCLICommandArguments(test.args) diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index c6bb654..e4843b1 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -31,6 +31,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com switch invocation.Command { case CommandInit: return executeInit(invocation.Options.(InitOptions)) + case CommandRehearsalInit: + return executeRehearsalInit(invocation.Options.(RehearsalInitOptions)) case CommandInspect: return executeInspect(invocation.Options.(InspectOptions)) case CommandPhase1Contribute: @@ -120,7 +122,7 @@ func executeInit(options InitOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := mpcceremony.CompileForKeyVersion(options.KeyVersion) if err != nil { return CommandResult{}, err } @@ -454,7 +456,7 @@ func executeFinalize(options FinalizeOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := compileCircuitForCeremony(trust) if err != nil { return CommandResult{}, err } @@ -503,7 +505,7 @@ func executePrepareFinalization(options PrepareFinalizationOptions) (CommandResu if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := compileCircuitForCeremony(trust) if err != nil { return CommandResult{}, err } @@ -548,7 +550,7 @@ func executeAudit(options AuditOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := compileCircuitForCeremony(trust) if err != nil { return CommandResult{}, err } @@ -898,3 +900,17 @@ func executeInspect(options InspectOptions) (CommandResult, error) { Outputs: outputs, }, nil } + +// compileCircuitForCeremony compiles the circuit the signed definition names. +// +// The key version comes from the definition rather than a flag, so an operator +// cannot select a different circuit than the ceremony was created with. An +// unknown or mismatched version fails in CompileForKeyVersion, and the compiled +// binding is compared against the definition again before anything is accepted. +func compileCircuitForCeremony(trust mpcceremony.TrustPaths) (*mpcceremony.CompiledCircuit, error) { + trusted, err := mpcceremony.LoadSignedDefinition(trust) + if err != nil { + return nil, err + } + return mpcceremony.CompileForKeyVersion(trusted.Definition.Circuit.KeyVersion) +} diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index efac782..1e03db8 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -17,6 +17,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { topics := [][]string{ nil, {"init"}, + {"rehearsal"}, + {"rehearsal", "init"}, {"phase1"}, {"phase1", "contribute"}, {"phase1", "attest-erasure"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 1d1c687..edbbd9b 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -240,7 +240,8 @@ func identifyCLICommandArguments(args []string) map[int]struct{} { command: topLevel := map[string]struct{}{ "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, - "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "release": {}, + "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "rehearsal": {}, + "release": {}, } if _, ok := topLevel[args[index]]; !ok { return safe @@ -264,7 +265,8 @@ command: "export-signing": {}, "help": {}, "import-signature": {}, "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "verify": {}, }, - "release": {"help": {}, "sign": {}, "verify": {}}, + "release": {"help": {}, "sign": {}, "verify": {}}, + "rehearsal": {"help": {}, "init": {}}, } allowed, hasSubcommands := subcommands[args[index]] if hasSubcommands && index+1 < len(args) { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index e28d512..c098738 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -16,6 +16,12 @@ import ( const supportedKeyVersion = "ownership-destination-v2" +// rehearsalKeyVersion selects the tiny circuit used to exercise the ceremony at +// a small domain. It is accepted here only alongside --mode rehearsal; the +// signed definition enforces the same rule independently, so this check is +// convenience rather than the control. +const rehearsalKeyVersion = "rehearsal-tiny-v1" + type helpRequest struct { topic []string } @@ -61,6 +67,8 @@ func parseInvocation(args []string) (Invocation, error) { options, err := parseInit(rest[1:]) invocation.Command, invocation.Options = CommandInit, options return invocation, wrapCommandError(err, "init") + case "rehearsal": + return parseRehearsal(invocation, rest[1:]) case "inspect": if len(rest) > 1 && !strings.HasPrefix(rest[1], "-") { return parseInspectSubcommand(invocation, rest[1:]) @@ -91,6 +99,40 @@ func parseInvocation(args []string) (Invocation, error) { } } +func parseRehearsal(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing rehearsal command", topic: []string{"rehearsal"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"rehearsal"}, args[1:]...)} + } + switch args[0] { + case "init": + options, err := parseRehearsalInit(args[1:]) + invocation.Command, invocation.Options = CommandRehearsalInit, options + return invocation, wrapCommandError(err, "rehearsal", "init") + default: + return Invocation{}, &usageError{ + message: fmt.Sprintf("unknown rehearsal command %q", args[0]), + topic: []string{"rehearsal"}, + } + } +} + +func parseRehearsalInit(args []string) (RehearsalInitOptions, error) { + var options RehearsalInitOptions + fs := commandFlagSet("rehearsal init") + fs.StringVar(&options.CreatedAt, "created-at", "", "ceremony creation timestamp in RFC3339") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh rehearsal work directory") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + value("--created-at", options.CreatedAt), + pathValue("--out-dir", options.OutDir), + ) +} + func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, &usageError{message: "missing inspect command", topic: []string{"inspect"}} @@ -605,7 +647,7 @@ func parseInit(args []string) (InitOptions, error) { fs := commandFlagSet("init") fs.StringVar(&options.SessionNonceHex, "session-nonce-hex", "", "optional 32-byte session nonce as hex; generated securely when omitted") fs.StringVar(&options.CreatedAt, "created-at", "", "ceremony creation timestamp in RFC3339") - fs.StringVar(&options.KeyVersion, "key-version", "", "repository key version (ownership-destination-v2 only)") + fs.StringVar(&options.KeyVersion, "key-version", "", "repository key version (ownership-destination-v2, or rehearsal-tiny-v1 with --mode rehearsal)") fs.StringVar(&options.ParticipantsPath, "participants", "", "participant roster JSON path") fs.StringVar(&options.PolicyPath, "policy", "", "ceremony policy JSON path") fs.StringVar(&options.CoordinatorKeyID, "coordinator-key-id", "", "coordinator signing key identifier") @@ -618,8 +660,16 @@ func parseInit(args []string) (InitOptions, error) { if options.Mode != "rehearsal" && options.Mode != "production" { return options, errors.New("--mode must be rehearsal or production") } - if options.KeyVersion != "" && options.KeyVersion != supportedKeyVersion { - return options, fmt.Errorf("--key-version must be %q", supportedKeyVersion) + switch options.KeyVersion { + case "", supportedKeyVersion: + case rehearsalKeyVersion: + if options.Mode != "rehearsal" { + return options, fmt.Errorf( + "--key-version %q requires --mode rehearsal", rehearsalKeyVersion) + } + default: + return options, fmt.Errorf( + "--key-version must be %q or %q", supportedKeyVersion, rehearsalKeyVersion) } if options.SessionNonceHex != "" { raw, err := hex.DecodeString(options.SessionNonceHex) diff --git a/cmd/mpc-ceremony/rehearsal.go b/cmd/mpc-ceremony/rehearsal.go new file mode 100644 index 0000000..3c2fa71 --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal.go @@ -0,0 +1,63 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "path/filepath" + + "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" +) + +const ( + rehearsalParticipantCount = 3 + rehearsalBeaconLeadSeconds = 300 +) + +func executeRehearsalInit(options RehearsalInitOptions) (result CommandResult, err error) { + if err := mpcrehearsal.Generate( + options.OutDir, + rehearsalParticipantCount, + rehearsalBeaconLeadSeconds, + ); err != nil { + return CommandResult{}, err + } + keepRoot := false + defer func() { + if !keepRoot { + err = errors.Join(err, os.RemoveAll(options.OutDir)) + } + }() + + configRoot := filepath.Join(options.OutDir, "config") + keyRoot := filepath.Join(options.OutDir, "keys") + participantsPath := filepath.Join(configRoot, "participants.json") + participants, err := mpcceremony.LoadInitParticipants(participantsPath) + if err != nil { + return CommandResult{}, err + } + result, err = executeInit(InitOptions{ + CreatedAt: options.CreatedAt, + KeyVersion: rehearsalKeyVersion, + ParticipantsPath: participantsPath, + PolicyPath: filepath.Join(configRoot, "policy.json"), + CoordinatorKeyID: participants.Coordinator.KeyID, + CoordinatorSigningKey: filepath.Join(keyRoot, "coordinator.ed25519.private.hex"), + OutDir: filepath.Join(options.OutDir, "public"), + Mode: mpcceremony.ModeRehearsal, + }) + if err != nil { + return CommandResult{}, err + } + result.Command = CommandRehearsalInit + result.Summary = "initialized same-host three-participant rehearsal fixture (NOT PRODUCTION)" + result.Outputs["config_root"] = configRoot + result.Outputs["environment"] = filepath.Join(configRoot, "environment.json") + result.Outputs["key_root"] = keyRoot + result.Outputs["rehearsal_root"] = options.OutDir + keepRoot = true + return result, nil +} diff --git a/cmd/mpc-ceremony/rehearsal_test.go b/cmd/mpc-ceremony/rehearsal_test.go new file mode 100644 index 0000000..163343f --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +func TestParseRehearsalInitIsNarrowAndExplicit(t *testing.T) { + t.Parallel() + + invocation, err := parseInvocation([]string{ + "rehearsal", "init", + "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", + }) + if err != nil { + t.Fatal(err) + } + if invocation.Command != CommandRehearsalInit { + t.Fatalf("command = %q", invocation.Command) + } + options := invocation.Options.(RehearsalInitOptions) + if options.CreatedAt != "2026-08-20T06:00:00Z" || options.OutDir != "/secure/rehearsal" { + t.Fatalf("options = %+v", options) + } + + for name, args := range map[string][]string{ + "missing creation time": {"rehearsal", "init", "--out-dir", "/secure/rehearsal"}, + "missing output": {"rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z"}, + "production mode": { + "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", "--mode", "production", + }, + "production circuit": { + "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", "--key-version", supportedKeyVersion, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + if _, err := parseInvocation(args); err == nil { + t.Fatal("unsafe rehearsal initializer invocation was accepted") + } + }) + } +} + +func TestRehearsalInitHelpLabelsOutputAsNonProduction(t *testing.T) { + t.Parallel() + + var output strings.Builder + if err := writeUsage(&output, []string{"rehearsal", "init"}); err != nil { + t.Fatal(err) + } + lower := strings.ToLower(output.String()) + if !strings.Contains(lower, "rehearsal-tiny-v1") || !strings.Contains(lower, "not production") { + t.Fatalf("help does not state the rehearsal boundary: %q", output.String()) + } +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 6cbb7f2..1ea3cbd 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -16,6 +16,7 @@ type Command string const ( CommandInit Command = "init" + CommandRehearsalInit Command = "rehearsal init" CommandInspect Command = "inspect" CommandPhase1Contribute Command = "phase1 contribute" CommandPhase1Erasure Command = "phase1 attest-erasure" @@ -71,6 +72,11 @@ type InitOptions struct { Mode string } +type RehearsalInitOptions struct { + CreatedAt string + OutDir string +} + type ContributeOptions struct { CeremonyPath string CeremonySignaturePath string diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index d3400e0..8684621 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -23,11 +23,14 @@ const rootHelp = `Usage: mpc-ceremony [--format human|json] [--quiet] [flags] Offline, append-only orchestration for this repository's BLS12-381 Groth16 -multi-party setup. The binary accepts setup artifacts and signing keys only. -It performs no network access and never selects a mutable "latest" artifact. +multi-party setup. Production commands accept operator-supplied artifacts and +signing keys only; the explicitly rehearsal-only initializer creates same-host +test identities. The binary performs no network access and never selects a +mutable "latest" artifact. Commands: init Bind a ceremony to the compiled repository circuit + rehearsal init Create and initialize a three-party tiny rehearsal inspect Report chain state and next scheduled contribution phase1 contribute Verify the full phase 1 chain and contribute phase1 attest-erasure Sign a participant destruction attestation @@ -105,6 +108,19 @@ second path list. ` var commandHelp = map[string]string{ + "rehearsal": `Usage: + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR + +Rehearsal commands create same-host test identities and must never be used as +production enrollment evidence. +`, + "rehearsal init": `Usage: + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR + +Creates fresh same-host identities and canonical configuration for exactly +three participants, then initializes a signed rehearsal-tiny-v1 ceremony. The +output is a functional test fixture, not production or independence evidence. +`, "inspect": inspectHelp + ` Authenticated record projections are also available as subcommands: mpc-ceremony inspect [flags] diff --git a/internal/circuit/rehearsal/circuit.go b/internal/circuit/rehearsal/circuit.go new file mode 100644 index 0000000..8fbb522 --- /dev/null +++ b/internal/circuit/rehearsal/circuit.go @@ -0,0 +1,73 @@ +// Package rehearsal defines a deliberately tiny circuit used only to exercise +// the MPC ceremony machinery. +// +// The production destination-v2 circuit has roughly 1.79 million constraints, +// which forces an FFT domain of 2^21. That makes every ceremony operation +// expensive: a single contribution moves 604 MiB and takes minutes, a phase +// close replays the whole accepted chain and takes over an hour, and a full +// rehearsal is a multi-day exercise. Testing the orchestration around the +// ceremony at that size is impractical. +// +// This circuit proves a trivial statement at a small domain so the same +// orchestration can be exercised in seconds. It proves nothing useful and must +// never appear in a production ceremony; CeremonyDefinition rejects it whenever +// mode is production, and the K21 rehearsal gate in the production decision +// continues to demand domain 2^21 so a run at this size can never satisfy it. +package rehearsal + +import ( + "errors" + "math/big" + + "github.com/consensys/gnark/frontend" +) + +const ( + // CircuitID names this circuit in a ceremony definition. The "rehearsal" + // prefix is load bearing: it is what a reader sees in ceremony.json, and it + // must be obvious at a glance that a transcript is not production evidence. + CircuitID = "rehearsal-tiny-v1/bls12-381/groth16" + + // KeyVersion is the value passed to init --key-version to select this + // circuit. + KeyVersion = "rehearsal-tiny-v1" +) + +// Circuit proves knowledge of a value whose cube equals the public input. The +// statement is arbitrary; what matters is that it compiles to a handful of +// constraints and therefore a small domain. +type Circuit struct { + X frontend.Variable + Pub frontend.Variable `gnark:",public"` +} + +func (c *Circuit) Define(api frontend.API) error { + cube := api.Mul(api.Mul(c.X, c.X), c.X) + api.AssertIsEqual(cube, c.Pub) + + // Exactly one Groth16 commitment, matching destination-v2. + // + // This is not decoration. Finalization exports a Cardano-format verifying + // key whose BSB22 encoding assumes a single commitment, so a circuit with + // none cannot be finalized at all. Without this the rehearsal circuit could + // exercise the ceremony only as far as the beacon, and the finalize, + // audit and release stages would stay untestable. + committer, ok := api.(frontend.Committer) + if !ok { + return errors.New("rehearsal circuit requires a committer API") + } + commitment, err := committer.Commit(c.X) + if err != nil { + return err + } + api.AssertIsDifferent(commitment, 0) + return nil +} + +// Assignment builds a satisfying witness for the given secret. +func Assignment(x int64) *Circuit { + value := big.NewInt(x) + cube := new(big.Int).Mul(value, value) + cube.Mul(cube, value) + return &Circuit{X: value, Pub: cube} +} diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index 194ea5e..abad5d0 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -137,6 +137,20 @@ func (d CeremonyDefinition) validate(requireID bool) error { switch d.Mode { case ModeRehearsal: case ModeProduction: + // The circuit registry accepts a tiny rehearsal circuit so the ceremony + // machinery can be exercised at a small domain. Production must never + // see it: a transcript at domain 2^16 proves nothing about a 2^21 + // ceremony, and the exact-k21-rehearsal gate exists precisely so a + // smaller run cannot satisfy it. This is the only place that knows the + // mode, so it is the only place the restriction can live, and it is + // decided before any environment-dependent check so the failure is + // about the definition rather than the host. + if d.Circuit.KeyVersion != KeyVersionDestinationV2 { + return fmt.Errorf( + "production ceremony must use key_version %q, not %q", + KeyVersionDestinationV2, d.Circuit.KeyVersion, + ) + } if d.Software.SourceDirty { return errors.New("production ceremony requires a clean source tree") } diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index 4eb29a5..aa15c5c 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -33,6 +33,11 @@ const ( KeyVersionDestinationV2 = "ownership-destination-v2" CircuitIDDestinationV2 = "root-ownership-destination-v2/bls12-381/groth16" + // KeyVersionRehearsal names the tiny circuit used to exercise the ceremony + // machinery at a small domain. It is accepted only when mode is rehearsal; + // see CeremonyDefinition.validate. + KeyVersionRehearsal = "rehearsal-tiny-v1" + CircuitIDRehearsal = "rehearsal-tiny-v1/bls12-381/groth16" CurveBLS12381 = "BLS12-381" BackendGroth16 = "groth16" GnarkVersion = "v0.15.0" @@ -220,11 +225,23 @@ type CircuitBinding struct { } func (b CircuitBinding) Validate() error { - if b.KeyVersion != KeyVersionDestinationV2 { - return fmt.Errorf("key_version %q, want %q", b.KeyVersion, KeyVersionDestinationV2) - } - if b.CircuitID != CircuitIDDestinationV2 { - return fmt.Errorf("circuit_id %q, want %q", b.CircuitID, CircuitIDDestinationV2) + // Key version and circuit id are checked as a pair, not independently. A + // definition naming one circuit's version with another's id would otherwise + // pass both checks separately while describing nothing that exists. + // + // This is membership in a closed set rather than equality with a single + // constant, which is a weaker check than it replaced. What restores the + // strength is that a production definition may only name destination-v2; + // CeremonyDefinition.validate enforces that, and it is the only place that + // knows the mode. + switch { + case b.KeyVersion == KeyVersionDestinationV2 && b.CircuitID == CircuitIDDestinationV2: + case b.KeyVersion == KeyVersionRehearsal && b.CircuitID == CircuitIDRehearsal: + default: + return fmt.Errorf( + "key_version %q with circuit_id %q is not a known circuit", + b.KeyVersion, b.CircuitID, + ) } if b.Curve != CurveBLS12381 { return fmt.Errorf("curve %q, want %q", b.Curve, CurveBLS12381) diff --git a/internal/mpcceremony/r1cs.go b/internal/mpcceremony/r1cs.go index 96f50d0..406e7ef 100644 --- a/internal/mpcceremony/r1cs.go +++ b/internal/mpcceremony/r1cs.go @@ -15,6 +15,11 @@ import ( "github.com/consensys/gnark/backend/groth16" "github.com/consensys/gnark/constraint" bls12381cs "github.com/consensys/gnark/constraint/bls12-381" + "github.com/consensys/gnark/frontend" + r1csbuilder "github.com/consensys/gnark/frontend/cs/r1cs" + + "proof-tool/internal/circuit/rehearsal" + "golang.org/x/crypto/blake2b" "proof-tool/internal/keyprofile" @@ -114,7 +119,11 @@ func ReadR1CSFile(path string, expected CircuitBinding) (*CompiledCircuit, error ); err != nil { return nil, fmt.Errorf("decode frozen R1CS %q: %w", path, err) } - compiled, err := bindDestinationV2R1CS(native) + // Bind using the identity the signed definition names, not a fixed one. + // The result is compared against that same expected binding immediately + // below, so this cannot be used to accept a circuit the definition did not + // ask for: it only decides which rules the file is checked against. + compiled, err := bindForKeyVersion(native, expected.KeyVersion) if err != nil { return nil, fmt.Errorf("validate frozen R1CS %q: %w", path, err) } @@ -157,6 +166,24 @@ func WriteR1CSFileNoReplace(path string, circuit *CompiledCircuit) (Digest, erro } func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircuit, error) { + return bindR1CS(compiled, KeyVersionDestinationV2, CircuitIDDestinationV2, destinationV2CommitmentCount) +} + +// bindR1CS derives the circuit binding for a compiled constraint system. +// +// Identity and expected commitment count are parameters rather than constants +// because the ceremony supports a second, deliberately tiny circuit for +// rehearsals. Every other rule here is unchanged and applies to both: the +// scalar field, the domain, the variable counts and the exact serialized +// digest are checked identically, so a rehearsal transcript is as internally +// consistent as a production one. What separates them is which key version a +// definition may name, which CeremonyDefinition decides using the mode. +func bindR1CS( + compiled constraint.ConstraintSystem, + keyVersion string, + circuitID string, + wantCommitments int, +) (*CompiledCircuit, error) { if compiled == nil { return nil, errors.New("constraint system is required") } @@ -190,11 +217,12 @@ func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircu if err != nil { return nil, err } - if len(commitments) != destinationV2CommitmentCount { + if len(commitments) != wantCommitments { return nil, fmt.Errorf( - "destination-v2 constraint system has %d commitments, want %d", + "%s constraint system has %d commitments, want %d", + keyVersion, len(commitments), - destinationV2CommitmentCount, + wantCommitments, ) } @@ -207,8 +235,8 @@ func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircu return nil, err } binding := CircuitBinding{ - KeyVersion: KeyVersionDestinationV2, - CircuitID: CircuitIDDestinationV2, + KeyVersion: keyVersion, + CircuitID: circuitID, Curve: CurveBLS12381, Backend: BackendGroth16, R1CS: ArtifactRef{Name: prover.DestinationConstraintSystemFile, Digest: digest}, @@ -220,7 +248,7 @@ func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircu Phase2Shape: phase2Shape, } if err := binding.Validate(); err != nil { - return nil, fmt.Errorf("derived destination-v2 circuit binding: %w", err) + return nil, fmt.Errorf("derived %s circuit binding: %w", keyVersion, err) } return &CompiledCircuit{R1CS: native, Binding: binding, validated: true}, nil } @@ -446,3 +474,67 @@ func equalPhase2Shape(left, right Phase2Shape) bool { } return true } + +// rehearsalCommitmentCount is the number of Groth16 commitments the rehearsal +// circuit produces. It matches destination-v2 deliberately: finalization +// exports a Cardano verifying key whose BSB22 encoding assumes exactly one +// commitment, so a circuit with a different count cannot be finalized and the +// later ceremony stages would be untestable. +const rehearsalCommitmentCount = destinationV2CommitmentCount + +// CompileForKeyVersion compiles the circuit a ceremony definition names. +// +// This is the one place that maps a key version to a circuit, and it is +// deliberately a closed set rather than a lookup that could be extended by a +// definition. An unknown key version is an error, not a request. +// +// Selecting the rehearsal circuit here does not make a rehearsal ceremony +// acceptable in production: CeremonyDefinition.validate rejects any key version +// other than destination-v2 when mode is production, and the K21 rehearsal gate +// in the production decision continues to require domain 2^21. +func CompileForKeyVersion(keyVersion string) (*CompiledCircuit, error) { + switch keyVersion { + case KeyVersionDestinationV2: + return CompileDestinationV2() + case KeyVersionRehearsal: + return compileRehearsal() + default: + return nil, fmt.Errorf( + "unknown key_version %q: want %q or %q", + keyVersion, KeyVersionDestinationV2, KeyVersionRehearsal, + ) + } +} + +func compileRehearsal() (*CompiledCircuit, error) { + compiled, err := frontend.Compile( + ecc.BLS12_381.ScalarField(), + r1csbuilder.NewBuilder, + &rehearsal.Circuit{}, + ) + if err != nil { + return nil, fmt.Errorf("compile rehearsal circuit: %w", err) + } + return bindR1CS(compiled, KeyVersionRehearsal, CircuitIDRehearsal, rehearsalCommitmentCount) +} + +// bindForKeyVersion applies the binding rules for a named circuit. +// +// Both circuits carry exactly one Groth16 commitment, and every other rule - +// scalar field, domain, variable counts, exact serialized digest - is applied +// identically. That is what makes a rehearsal transcript internally consistent +// in the same way a production one is; the circuits differ in what they prove +// and in the domain they need, not in how they are bound. +func bindForKeyVersion(compiled constraint.ConstraintSystem, keyVersion string) (*CompiledCircuit, error) { + switch keyVersion { + case KeyVersionDestinationV2: + return bindR1CS(compiled, KeyVersionDestinationV2, CircuitIDDestinationV2, destinationV2CommitmentCount) + case KeyVersionRehearsal: + return bindR1CS(compiled, KeyVersionRehearsal, CircuitIDRehearsal, rehearsalCommitmentCount) + default: + return nil, fmt.Errorf( + "unknown key_version %q: want %q or %q", + keyVersion, KeyVersionDestinationV2, KeyVersionRehearsal, + ) + } +} diff --git a/internal/mpcceremony/rehearsal_circuit_test.go b/internal/mpcceremony/rehearsal_circuit_test.go new file mode 100644 index 0000000..9916fa4 --- /dev/null +++ b/internal/mpcceremony/rehearsal_circuit_test.go @@ -0,0 +1,127 @@ +package mpcceremony + +import ( + "strings" + "testing" +) + +// TestCompileForKeyVersionRejectsUnknown keeps the registry a closed set. An +// unknown key version must be an error rather than a request the definition +// gets to make. +func TestCompileForKeyVersionRejectsUnknown(t *testing.T) { + for _, keyVersion := range []string{ + "", "ownership", "ownership-destination-v3", + "rehearsal-tiny-v2", " rehearsal-tiny-v1", + } { + if _, err := CompileForKeyVersion(keyVersion); err == nil { + t.Errorf("CompileForKeyVersion(%q) accepted an unknown circuit", keyVersion) + } + } +} + +func TestRehearsalCircuitCompilesSmall(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatalf("CompileForKeyVersion: %v", err) + } + if circuit.Binding.KeyVersion != KeyVersionRehearsal || + circuit.Binding.CircuitID != CircuitIDRehearsal { + t.Fatalf("binding identity is %+v", circuit.Binding) + } + // The entire point is a small domain. If the rehearsal circuit ever grew to + // production scale it would stop being useful and this test should fail + // rather than quietly cost minutes per contribution. + if circuit.Binding.DomainSize > 1<<12 { + t.Fatalf("rehearsal domain is %d, expected something tiny", circuit.Binding.DomainSize) + } + if circuit.Binding.Curve != CurveBLS12381 || circuit.Binding.Backend != BackendGroth16 { + t.Fatalf("rehearsal circuit must use the same curve and backend: %+v", circuit.Binding) + } +} + +// TestCircuitBindingChecksIdentityAsAPair guards the weakness introduced by +// moving from equality with one constant to membership in a set: a definition +// naming one circuit's key version with another's circuit id would otherwise +// satisfy two independent checks while describing nothing that exists. +func TestCircuitBindingChecksIdentityAsAPair(t *testing.T) { + base, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + mixed := base.Binding + mixed.CircuitID = CircuitIDDestinationV2 + if err := mixed.Validate(); err == nil { + t.Fatal("Validate accepted a rehearsal key_version with the destination-v2 circuit_id") + } + + swapped := base.Binding + swapped.KeyVersion = KeyVersionDestinationV2 + if err := swapped.Validate(); err == nil { + t.Fatal("Validate accepted a destination-v2 key_version with the rehearsal circuit_id") + } +} + +// TestProductionRejectsRehearsalCircuit is the guard that restores what the +// membership check gave up. A rehearsal transcript proves nothing about a +// production ceremony, and the definition is the only place that knows the mode. +func TestProductionRejectsRehearsalCircuit(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + definition := CeremonyDefinition{ + Schema: DefinitionSchema, + Mode: ModeProduction, + Circuit: circuit.Binding, + } + err = definition.validate(false) + if err == nil { + t.Fatal("a production definition accepted the rehearsal circuit") + } + if !strings.Contains(err.Error(), KeyVersionDestinationV2) { + t.Fatalf("error should name the required key version, got: %v", err) + } +} + +// TestRehearsalModeAcceptsRehearsalCircuit confirms the guard is conditional on +// the mode rather than rejecting the circuit outright, which would make the +// whole change pointless. +func TestRehearsalModeAcceptsRehearsalCircuit(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + definition := CeremonyDefinition{ + Schema: DefinitionSchema, + Mode: ModeRehearsal, + Circuit: circuit.Binding, + } + // The definition is otherwise empty, so validation fails on later fields. + // What matters is that it does not fail on the circuit identity. + err = definition.validate(false) + if err != nil && strings.Contains(err.Error(), "key_version") { + t.Fatalf("rehearsal mode rejected the rehearsal circuit: %v", err) + } +} + +// TestK21GateIgnoresRehearsalCircuit is the check that keeps a fast rehearsal +// from ever satisfying a production gate. K21RehearsalEvidence must continue to +// demand the production circuit at domain 2^21 regardless of what the registry +// now knows about. +func TestK21GateIgnoresRehearsalCircuit(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + evidence := K21RehearsalEvidence{ + KeyVersion: circuit.Binding.KeyVersion, + CircuitID: circuit.Binding.CircuitID, + Curve: circuit.Binding.Curve, + Backend: circuit.Binding.Backend, + Constraints: circuit.Binding.Constraints, + DomainSize: circuit.Binding.DomainSize, + } + if err := evidence.Validate(); err == nil { + t.Fatal("the K21 rehearsal gate accepted evidence from the tiny rehearsal circuit") + } +} diff --git a/internal/mpcrehearsal/config.go b/internal/mpcrehearsal/config.go new file mode 100644 index 0000000..dfe8754 --- /dev/null +++ b/internal/mpcrehearsal/config.go @@ -0,0 +1,243 @@ +// Package mpcrehearsal creates fresh same-host identities and exact canonical +// inputs for a local MPC ceremony rehearsal. It is deliberately not a +// production enrollment tool: production identities must be generated and +// governed independently by their owners. +package mpcrehearsal + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "proof-tool/internal/mpcceremony" +) + +const ( + minRehearsalParticipants = 3 + maxRehearsalParticipants = 20 + minRehearsalBeaconLead = 60 +) + +type generatedIdentity struct { + identity mpcceremony.Identity + privateKey ed25519.PrivateKey +} + +func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (err error) { + if participantCount < minRehearsalParticipants || + participantCount > maxRehearsalParticipants { + return fmt.Errorf( + "participants must be between %d and %d", + minRehearsalParticipants, + maxRehearsalParticipants, + ) + } + if beaconWitnessLead < minRehearsalBeaconLead { + return fmt.Errorf( + "beacon witness lead must be at least %d seconds", + minRehearsalBeaconLead, + ) + } + if err := os.Mkdir(outDir, 0o700); err != nil { + return fmt.Errorf("create fresh rehearsal config root: %w", err) + } + removeRoot := true + defer func() { + if err != nil && removeRoot { + _ = os.RemoveAll(outDir) + } + }() + keyDir := filepath.Join(outDir, "keys") + configDir := filepath.Join(outDir, "config") + for _, path := range []string{keyDir, configDir} { + if err := os.Mkdir(path, 0o700); err != nil { + return err + } + } + + newIdentity := func(id, displayName string) (generatedIdentity, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return generatedIdentity{}, err + } + identity, err := mpcceremony.NewIdentity( + id, + displayName, + id+"-key", + publicKey, + ) + if err != nil { + return generatedIdentity{}, err + } + return generatedIdentity{identity: identity, privateKey: privateKey}, nil + } + + coordinator, err := newIdentity("coordinator", "Local Rehearsal Coordinator") + if err != nil { + return err + } + releaseSigner, err := newIdentity("release-signer", "Local Rehearsal Release Signer") + if err != nil { + return err + } + auditor1, err := newIdentity("auditor-01", "Local Rehearsal Auditor 01") + if err != nil { + return err + } + auditor2, err := newIdentity("auditor-02", "Local Rehearsal Auditor 02") + if err != nil { + return err + } + witness1, err := newIdentity("witness-01", "Local Rehearsal Public Witness 01") + if err != nil { + return err + } + witness2, err := newIdentity("witness-02", "Local Rehearsal Public Witness 02") + if err != nil { + return err + } + mirror1, err := newIdentity("mirror-01", "Local Rehearsal Mirror Operator 01") + if err != nil { + return err + } + mirror2, err := newIdentity("mirror-02", "Local Rehearsal Mirror Operator 02") + if err != nil { + return err + } + generated := []generatedIdentity{ + coordinator, + releaseSigner, + auditor1, + auditor2, + witness1, + witness2, + mirror1, + mirror2, + } + participants := make([]mpcceremony.Participant, 0, participantCount) + participantIDs := make([]string, 0, participantCount) + for index := 1; index <= participantCount; index++ { + id := fmt.Sprintf("participant-%02d", index) + participant, err := newIdentity(id, "Local Rehearsal "+id) + if err != nil { + return err + } + generated = append(generated, participant) + participants = append(participants, mpcceremony.Participant{Identity: participant.identity}) + participantIDs = append(participantIDs, id) + } + + for _, item := range generated { + seedPath := filepath.Join(keyDir, item.identity.ID+".ed25519.private.hex") + if err := writeNoReplace( + seedPath, + []byte(hex.EncodeToString(item.privateKey.Seed())+"\n"), + 0o600, + ); err != nil { + return err + } + publicPath := filepath.Join(keyDir, item.identity.ID+".ed25519.public.hex") + if err := writeNoReplace( + publicPath, + []byte(item.identity.Ed25519PublicKeyHex+"\n"), + 0o600, + ); err != nil { + return err + } + } + + enrollment := mpcceremony.InitParticipants{ + Coordinator: coordinator.identity, + ReleaseSigner: releaseSigner.identity, + Auditors: []mpcceremony.Identity{auditor1.identity, auditor2.identity}, + Roster: participants, + } + policy := mpcceremony.InitPolicy{ + Phase1Policy: mpcceremony.PhasePolicy{ + Participants: participantIDs, + Minimum: uint8(participantCount), + }, + Phase2Policy: mpcceremony.PhasePolicy{ + Participants: append([]string(nil), participantIDs...), + Minimum: uint8(participantCount), + }, + BeaconPolicy: mpcceremony.BeaconPolicy{ + Provider: mpcceremony.BeaconProviderDrand, + Network: mpcceremony.BeaconNetworkQuicknet, + ChainHashHex: mpcceremony.BeaconQuicknetChainHash, + PublicKeyHex: mpcceremony.BeaconQuicknetPublicKey, + Scheme: mpcceremony.BeaconQuicknetScheme, + GenesisTimeUnix: mpcceremony.BeaconQuicknetGenesis, + PeriodSeconds: mpcceremony.BeaconQuicknetPeriod, + Extraction: mpcceremony.BeaconExtractionV1, + MinimumChallengeBytes: 32, + MinimumWitnessLeadSeconds: beaconWitnessLead, + FutureRoundRequired: true, + }, + } + environment := mpcceremony.ContributionEnvironment{ + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + EntropySource: "operating-system-csprng", + SwapDisabled: true, + CrashDumpsDisabled: true, + TelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralDestructionRequired: true, + } + for name, value := range map[string]any{ + "participants.json": enrollment, + "policy.json": policy, + "environment.json": environment, + } { + data, err := mpcceremony.MarshalCanonical(value) + if err != nil { + return err + } + if err := writeNoReplace(filepath.Join(configDir, name), data, 0o600); err != nil { + return err + } + } + if err := writeNoReplace( + filepath.Join(outDir, "participant-count.txt"), + []byte(fmt.Sprintf("%d\n", participantCount)), + 0o600, + ); err != nil { + return err + } + removeRoot = false + return nil +} + +func writeNoReplace(path string, data []byte, mode os.FileMode) error { + if len(data) == 0 { + return errors.New("refusing to write empty rehearsal config") + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + remove := true + defer func() { + _ = file.Close() + if remove { + _ = os.Remove(path) + } + }() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + remove = false + return nil +} diff --git a/scripts/mpc-rehearsal-config/main.go b/scripts/mpc-rehearsal-config/main.go index fda685d..f4651d8 100644 --- a/scripts/mpc-rehearsal-config/main.go +++ b/scripts/mpc-rehearsal-config/main.go @@ -5,30 +5,13 @@ package main import ( - "crypto/ed25519" - "crypto/rand" - "encoding/hex" - "errors" "flag" "fmt" "os" - "path/filepath" - "runtime" - "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" ) -const ( - minRehearsalParticipants = 3 - maxRehearsalParticipants = 20 - minRehearsalBeaconLead = 60 -) - -type generatedIdentity struct { - identity mpcceremony.Identity - privateKey ed25519.PrivateKey -} - func main() { outDir := flag.String("out-dir", "", "fresh output directory") participantCount := flag.Int("participants", 3, "number of rehearsal participants (3-20)") @@ -53,216 +36,6 @@ func main() { fmt.Printf("OK: generated rehearsal-only identities and canonical config in %s\n", *outDir) } -func generate(outDir string, participantCount int, beaconWitnessLead uint32) (err error) { - if participantCount < minRehearsalParticipants || - participantCount > maxRehearsalParticipants { - return fmt.Errorf( - "participants must be between %d and %d", - minRehearsalParticipants, - maxRehearsalParticipants, - ) - } - if beaconWitnessLead < minRehearsalBeaconLead { - return fmt.Errorf( - "beacon witness lead must be at least %d seconds", - minRehearsalBeaconLead, - ) - } - if err := os.Mkdir(outDir, 0o700); err != nil { - return fmt.Errorf("create fresh rehearsal config root: %w", err) - } - removeRoot := true - defer func() { - if err != nil && removeRoot { - _ = os.RemoveAll(outDir) - } - }() - keyDir := filepath.Join(outDir, "keys") - configDir := filepath.Join(outDir, "config") - for _, path := range []string{keyDir, configDir} { - if err := os.Mkdir(path, 0o700); err != nil { - return err - } - } - - newIdentity := func(id, displayName string) (generatedIdentity, error) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return generatedIdentity{}, err - } - identity, err := mpcceremony.NewIdentity( - id, - displayName, - id+"-key", - publicKey, - ) - if err != nil { - return generatedIdentity{}, err - } - return generatedIdentity{identity: identity, privateKey: privateKey}, nil - } - - coordinator, err := newIdentity("coordinator", "Local Rehearsal Coordinator") - if err != nil { - return err - } - releaseSigner, err := newIdentity("release-signer", "Local Rehearsal Release Signer") - if err != nil { - return err - } - auditor1, err := newIdentity("auditor-01", "Local Rehearsal Auditor 01") - if err != nil { - return err - } - auditor2, err := newIdentity("auditor-02", "Local Rehearsal Auditor 02") - if err != nil { - return err - } - witness1, err := newIdentity("witness-01", "Local Rehearsal Public Witness 01") - if err != nil { - return err - } - witness2, err := newIdentity("witness-02", "Local Rehearsal Public Witness 02") - if err != nil { - return err - } - mirror1, err := newIdentity("mirror-01", "Local Rehearsal Mirror Operator 01") - if err != nil { - return err - } - mirror2, err := newIdentity("mirror-02", "Local Rehearsal Mirror Operator 02") - if err != nil { - return err - } - generated := []generatedIdentity{ - coordinator, - releaseSigner, - auditor1, - auditor2, - witness1, - witness2, - mirror1, - mirror2, - } - participants := make([]mpcceremony.Participant, 0, participantCount) - participantIDs := make([]string, 0, participantCount) - for index := 1; index <= participantCount; index++ { - id := fmt.Sprintf("participant-%02d", index) - participant, err := newIdentity(id, "Local Rehearsal "+id) - if err != nil { - return err - } - generated = append(generated, participant) - participants = append(participants, mpcceremony.Participant{Identity: participant.identity}) - participantIDs = append(participantIDs, id) - } - - for _, item := range generated { - seedPath := filepath.Join(keyDir, item.identity.ID+".ed25519.private.hex") - if err := writeNoReplace( - seedPath, - []byte(hex.EncodeToString(item.privateKey.Seed())+"\n"), - 0o600, - ); err != nil { - return err - } - publicPath := filepath.Join(keyDir, item.identity.ID+".ed25519.public.hex") - if err := writeNoReplace( - publicPath, - []byte(item.identity.Ed25519PublicKeyHex+"\n"), - 0o600, - ); err != nil { - return err - } - } - - enrollment := mpcceremony.InitParticipants{ - Coordinator: coordinator.identity, - ReleaseSigner: releaseSigner.identity, - Auditors: []mpcceremony.Identity{auditor1.identity, auditor2.identity}, - Roster: participants, - } - policy := mpcceremony.InitPolicy{ - Phase1Policy: mpcceremony.PhasePolicy{ - Participants: participantIDs, - Minimum: uint8(participantCount), - }, - Phase2Policy: mpcceremony.PhasePolicy{ - Participants: append([]string(nil), participantIDs...), - Minimum: uint8(participantCount), - }, - BeaconPolicy: mpcceremony.BeaconPolicy{ - Provider: mpcceremony.BeaconProviderDrand, - Network: mpcceremony.BeaconNetworkQuicknet, - ChainHashHex: mpcceremony.BeaconQuicknetChainHash, - PublicKeyHex: mpcceremony.BeaconQuicknetPublicKey, - Scheme: mpcceremony.BeaconQuicknetScheme, - GenesisTimeUnix: mpcceremony.BeaconQuicknetGenesis, - PeriodSeconds: mpcceremony.BeaconQuicknetPeriod, - Extraction: mpcceremony.BeaconExtractionV1, - MinimumChallengeBytes: 32, - MinimumWitnessLeadSeconds: beaconWitnessLead, - FutureRoundRequired: true, - }, - } - environment := mpcceremony.ContributionEnvironment{ - OS: runtime.GOOS, - Architecture: runtime.GOARCH, - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, - } - for name, value := range map[string]any{ - "participants.json": enrollment, - "policy.json": policy, - "environment.json": environment, - } { - data, err := mpcceremony.MarshalCanonical(value) - if err != nil { - return err - } - if err := writeNoReplace(filepath.Join(configDir, name), data, 0o600); err != nil { - return err - } - } - if err := writeNoReplace( - filepath.Join(outDir, "participant-count.txt"), - []byte(fmt.Sprintf("%d\n", participantCount)), - 0o600, - ); err != nil { - return err - } - removeRoot = false - return nil -} - -func writeNoReplace(path string, data []byte, mode os.FileMode) error { - if len(data) == 0 { - return errors.New("refusing to write empty rehearsal config") - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - remove := true - defer func() { - _ = file.Close() - if remove { - _ = os.Remove(path) - } - }() - if _, err := file.Write(data); err != nil { - return err - } - if err := file.Sync(); err != nil { - return err - } - if err := file.Close(); err != nil { - return err - } - remove = false - return nil +func generate(outDir string, participantCount int, beaconWitnessLead uint32) error { + return mpcrehearsal.Generate(outDir, participantCount, beaconWitnessLead) } From 2643d628bde0bf717c79ea20a7f41a81f76d786e Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 08:37:42 +0000 Subject: [PATCH 28/42] perf(mpc): parallelize ceremony hot paths --- .gitignore | 7 + CONTRIBUTING.md | 2 +- docs/README.md | 3 + docs/mpc-ceremony-parallel-optimizations.md | 248 +++++++ .../patches/mpc-phase1-parallel-codec.patch | 456 +++++++++++++ .../patches/mpc-phase1-parallel-update.patch | 194 ++++++ .../mpc-phase2-parallel-initialize.patch | 640 ++++++++++++++++++ scripts/bootstrap-vendor.sh | 12 +- scripts/check-vendor-drift.sh | 13 +- scripts/generate-go-sbom/main.go | 3 + scripts/verify-mpc-build-metadata/main.go | 3 + 11 files changed, 1571 insertions(+), 10 deletions(-) create mode 100644 docs/mpc-ceremony-parallel-optimizations.md create mode 100644 experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch create mode 100644 experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch create mode 100644 experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch diff --git a/.gitignore b/.gitignore index c156cff..1a33ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,9 @@ experiments/wasm-prover/patches/* !experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch !experiments/wasm-prover/patches/uints-constant-fold.patch !experiments/wasm-prover/patches/computeh-parallel-transforms.patch +!experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch +!experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch +!experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch !experiments/wasm-prover/runtime/** !experiments/wasm-prover/fault/** !experiments/wasm-prover/scripts/** @@ -90,3 +93,7 @@ experiments/wasm-prover/web/* /docs/ux-review-landing-and-claim-flow.md /contracts/ownership-verifier/testdata/*-review.md .vercel/ + +# Local Go build outputs. +/mpc-ceremony +/workflowhelper diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4666463..227ebc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ coherent). This file only covers the mechanics. ```bash pnpm install # repo root: installs Biome + lefthook, registers git hooks -bash scripts/bootstrap-vendor.sh # required: vendors gnark with the local ProveStream patch +bash scripts/bootstrap-vendor.sh # required: vendors gnark with the reviewed local patches ``` Never run plain `go mod vendor`; it drops the hand-applied patch. Use the diff --git a/docs/README.md b/docs/README.md index 0819617..ee7a18f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,6 +22,9 @@ that foundation. - [`trusted-setup-ceremony.md`](trusted-setup-ceremony.md): setup provenance and signed key-bundle handling, including the explicit boundary between local single-actor setup and MPC. +- [`mpc-ceremony-parallel-optimizations.md`](mpc-ceremony-parallel-optimizations.md): + gnark Phase 1/Phase 2 threading changes, safety invariants, benchmarks, and + the initial exact K=21 comparison result. - [`mpc-ceremony-runbook.md`](mpc-ceremony-runbook.md): production operator, contributor, auditor, beacon, archival, replay, and release gates for the dedicated two-phase BLS12-381 MPC ceremony. diff --git a/docs/mpc-ceremony-parallel-optimizations.md b/docs/mpc-ceremony-parallel-optimizations.md new file mode 100644 index 0000000..10fc976 --- /dev/null +++ b/docs/mpc-ceremony-parallel-optimizations.md @@ -0,0 +1,248 @@ +# MPC Ceremony Parallel Optimizations + +This note explains the three multithreading optimizations applied to gnark's +BLS12-381 Groth16 MPC implementation for the proof-tool ceremony. They change +how independent work is scheduled; they do not change the circuit, proof +statement, elliptic-curve formulas, transcript layout, or verification rules. + +The implementation is carried as reviewed patches against the repository's +pinned gnark v0.15.0 dependency: + +- `experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch` +- `experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch` +- `experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch` + +`scripts/bootstrap-vendor.sh` applies these patches when reconstructing the +gitignored `vendor/` tree. Vendor drift checks, release metadata, and SBOM +generation include all three patches. + +## Results Summary + +Controlled benchmarks were run on a 16-vCPU AMD EPYC host. The benchmark +fixtures use smaller domains than the complete ceremony, so these figures are +component measurements rather than end-to-end K=21 predictions. + +| Hot path | Serial median | Parallel median | Speedup | +|---|---:|---:|---:| +| Phase 1 point update | 574 ms | 50.8 ms | 11.3× | +| Phase 1 encoding | 8.01 ms | 1.79 ms | 4.5× | +| Phase 1 decoding | 1.33 s | 151 ms | 8.9× | +| Phase 2 initialization | 9.97 s | 1.14 s | 8.7× | + +The first result from the exact K=21 comparison rehearsal is: + +| Exact K=21 stage | Previous run | Optimized run | Speedup | +|---|---:|---:|---:| +| Ceremony initialization | 12m16s | 1m34.76s | 7.8× | + +The optimized initialization averaged 954% CPU according to GNU `time`, which +means it used about 9.54 CPU cores concurrently. It reached a peak resident set +size of approximately 3.38 GiB. + +## 1. Parallel Phase 1 Point Updates + +Each Phase 1 contribution updates millions of SRS points using fresh secret +scalars tau-prime, alpha-prime, and beta-prime. At index `i`, the work is +conceptually: + +```text +Tau[i] = Tau[i] * tau-prime^i +AlphaTau[i] = AlphaTau[i] * alpha-prime * tau-prime^i +BetaTau[i] = BetaTau[i] * beta-prime * tau-prime^i +``` + +### Previous behavior + +One thread walked every index in sequence. It maintained one running power of +tau-prime and performed all G1 and G2 scalar multiplications serially. + +### Parallel behavior + +The updated implementation divides the arrays into disjoint ranges. Each +worker: + +1. Computes `tau-prime^start` for the first index in its range. +2. Updates only the points in that range. +3. Advances its own local power of tau-prime after each point. + +The expensive first range, which updates G1 Tau, G2 Tau, AlphaTau, and +BetaTau, is scheduled separately from the lighter G1-Tau-only tail. This +prevents the lighter tail from distorting load balancing for the four-operation +range. + +```text +worker 0: [start 0 ................................ end 0) +worker 1: [start 1 ........ end 1) +worker 2: [start 2 ... end 2) +``` + +### Correctness and race safety + +- Worker ranges never overlap. +- Each worker owns its field elements and `big.Int` temporaries. +- Every index receives the same scalar as in the original serial loop. +- Alpha, beta, and the index-zero values retain their original handling. +- Equivalence tests compare the complete parallel SRS with the retained serial + reference implementation. +- The Go race detector passes for the patched package. + +## 2. Parallel Phase 1 Encoding and Decoding + +An exact K=21 Phase 1 artifact is approximately 576 MiB and contains millions +of compressed G1 and G2 points. + +### Previous behavior + +Gnark constructed a large `[]any` containing an individual reference to every +point. Its generic encoder or decoder then processed those references one at a +time. Point decompression, curve checks, and subgroup checks therefore ran +serially. + +### Parallel behavior + +The new codec processes bounded chunks of 4,096 points. + +Encoding: + +```text +compress each point concurrently into its fixed byte offset + | + v +write the completed chunk in canonical point order +``` + +Decoding: + +```text +read one fixed-width canonical chunk in point order + | + v +decode and validate each point concurrently +``` + +Chunks themselves are always read and written sequentially, so scheduling +cannot reorder artifact bytes. + +### Wire format and validation + +The encoded layout remains: + +```text +N +G2 Beta +G1 Tau[1:] +G2 Tau[1:] +G1 BetaTau +G1 AlphaTau +``` + +Every decoded point still undergoes: + +- canonical field-element decoding; +- curve membership validation; +- subgroup validation; and +- deterministic error selection in point order. + +The new reader consumes fixed compressed widths and therefore rejects +uncompressed encodings that gnark's generic decoder previously could consume. +This is intentional for proof-tool's strict canonical-artifact boundary, but it +is a behavior change in the patched gnark `SrsCommons.ReadFrom` method and must +remain explicitly documented and tested. + +Temporary codec memory is bounded by one chunk rather than scaling with the +number of SRS point references. + +## 3. Parallel Phase 2 Initialization + +Phase 2 derives circuit-specific Groth16 parameters from the sealed Phase 1 SRS +and the compiled R1CS. The optimization covers three areas. + +### 3.1 Lagrange group FFTs + +Initialization computes four large group transforms: + +- Tau in G1; +- Tau in G2; +- AlphaTau in G1; and +- BetaTau in G1. + +Gnark's previous recursive FFT split its two recursive halves across workers, +but each recursion node first completed a large butterfly pass serially. The +largest top-level pass therefore delayed all recursive parallelism. + +The new implementation parallelizes the butterfly pass itself under a fixed +CPU budget: + +```text +stage 0: 1 branch x 16 workers +stage 1: 2 branches x 8 workers +stage 2: 4 branches x 4 workers +stage 3: 8 branches x 2 workers +stage 4: 16 branches x 1 worker +``` + +Each butterfly operates on a distinct pair of points. No two workers write the +same point during a stage, and the total intended concurrency remains bounded +by the selected worker budget. + +### 3.2 Constraint accumulation + +Each R1CS constraint contributes to A, B, and C evaluations associated with +particular wires. Parallelizing constraints directly would allow multiple +workers to mutate the same wire. + +The implementation instead: + +1. Reads constraints sequentially in bounded batches of 16,384. +2. Assigns each wire to exactly one worker using `wireID % workerCount`. +3. Queues left, right, and output terms for the owning worker. +4. Processes those queues concurrently. +5. Completes the batch before reading the next one. + +Terms for a particular wire and expression side retain their original order. +Because a wire has exactly one owner, no locks are required and workers cannot +race on the output arrays. + +### 3.3 Independent point loops + +Two additional loops operate on independent output points and are now +parallel: + +- construction of the Z polynomial points; and +- computation of `beta*A + alpha*B + C` for every wire. + +## Security and Determinism Invariants + +The optimizations are designed around the following invariants: + +- Transcript and SRS bytes must not depend on goroutine scheduling. +- Workers may read shared immutable data but must own every point they mutate. +- Point decoding must retain canonical, curve, and subgroup validation. +- Additional memory must remain bounded at K=21. +- The pinned gnark version and all local patches must be represented in build + provenance and SBOM evidence. +- Serial and parallel implementations must produce byte-identical outputs for + deterministic fixtures. + +Focused equivalence and negative tests, the race detector, ceremony integration +tests, and vendor regeneration/drift checks pass. + +The equivalence fixtures use deterministic, distinct curve points. The Phase 2 +test compares both one- and four-worker execution with a retained copy of +gnark v0.15.0's serial initialization algorithm, including its serial group +FFTs. It also asserts that the fixture's inverse FFT is dense, preventing a +constant-vector delta from making most accumulation operations no-ops. The +codec test crosses the 4,096-point chunk boundary with a different point at +every serialized position, and a negative test records the intentional +rejection of otherwise valid uncompressed Phase 1 points. + +## Remaining Review Follow-up + +Execute FFT butterfly loops directly when a recursion node has only one +assigned task, avoiding unnecessary one-worker goroutine creation. This is a +small scheduling cleanup rather than a correctness requirement. + +The full exact K=21 comparison rehearsal remains the final performance and +coherence check. Its ceremony binary is pinned independently by SHA-256, and +its measurements record wall time, CPU time, peak memory, filesystem activity, +and exact command outputs for every stage. diff --git a/experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch b/experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch new file mode 100644 index 0000000..894210b --- /dev/null +++ b/experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch @@ -0,0 +1,456 @@ +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/marshal.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/marshal.go +@@ -152,56 +152,170 @@ + return n + dn, err + } + +-// refsSlice produces a slice consisting of references to all sub-elements +-// prepended by the size parameter, to be used in WriteTo and ReadFrom functions +-func (c *SrsCommons) refsSlice() []any { +- N := uint64(len(c.G2.Tau)) +- expectedLen := 5*N - 1 +- // size N 1 +- // [β]₂ 1 +- // [τⁱ]₁ for 1 ≤ i ≤ 2N-2 2N-2 +- // [τⁱ]₂ for 1 ≤ i ≤ N-1 N-1 +- // [ατⁱ]₁ for 0 ≤ i ≤ N-1 N +- // [βτⁱ]₁ for 0 ≤ i ≤ N-1 N +- refs := make([]any, 2, expectedLen) +- refs[0] = N +- refs[1] = &c.G2.Beta +- refs = utils.AppendRefs(refs, c.G1.Tau[1:]) +- refs = utils.AppendRefs(refs, c.G2.Tau[1:]) +- refs = utils.AppendRefs(refs, c.G1.BetaTau) +- refs = utils.AppendRefs(refs, c.G1.AlphaTau) +- +- if uint64(len(refs)) != expectedLen { +- panic("incorrect length estimate") +- } +- +- return refs +-} +- +-func (c *SrsCommons) WriteTo(writer io.Writer) (int64, error) { +- enc := curve.NewEncoder(writer) +- for _, v := range c.refsSlice() { +- if err := enc.Encode(v); err != nil { +- return enc.BytesWritten(), err ++const srsCodecChunkSize = 4096 ++ ++// WriteTo writes the canonical Phase 1 wire format. Point compression is done ++// in bounded parallel chunks, while chunks themselves are written in order. ++func (c *SrsCommons) WriteTo(writer io.Writer) (n int64, err error) { ++ var size [8]byte ++ binary.BigEndian.PutUint64(size[:], uint64(len(c.G2.Tau))) ++ if dn, writeErr := writeAll(writer, size[:]); writeErr != nil { ++ return int64(dn), writeErr ++ } ++ n = int64(len(size)) ++ ++ for _, write := range []func(io.Writer) (int64, error){ ++ func(w io.Writer) (int64, error) { return writeG2Point(w, &c.G2.Beta) }, ++ func(w io.Writer) (int64, error) { return writeG1Points(w, c.G1.Tau[1:]) }, ++ func(w io.Writer) (int64, error) { return writeG2Points(w, c.G2.Tau[1:]) }, ++ func(w io.Writer) (int64, error) { return writeG1Points(w, c.G1.BetaTau) }, ++ func(w io.Writer) (int64, error) { return writeG1Points(w, c.G1.AlphaTau) }, ++ } { ++ dn, writeErr := write(writer) ++ n += dn ++ if writeErr != nil { ++ return n, writeErr + } + } +- return enc.BytesWritten(), nil ++ return n, nil + } + +-// ReadFrom implements io.ReaderFrom ++// ReadFrom implements io.ReaderFrom. Each point still undergoes the full ++// canonical encoding, curve, and subgroup checks performed by SetBytes. + func (c *SrsCommons) ReadFrom(reader io.Reader) (n int64, err error) { +- var N uint64 +- dec := curve.NewDecoder(reader) +- if err = dec.Decode(&N); err != nil { +- return dec.BytesRead(), err ++ var size [8]byte ++ dn, err := io.ReadFull(reader, size[:]) ++ n = int64(dn) ++ if err != nil { ++ return n, err + } + ++ N := binary.BigEndian.Uint64(size[:]) + c.setContributionsZero(N) + +- for _, v := range c.refsSlice()[1:] { // we've already decoded N +- if err = dec.Decode(v); err != nil { +- return dec.BytesRead(), err ++ for _, read := range []func(io.Reader) (int64, error){ ++ func(r io.Reader) (int64, error) { return readG2Point(r, &c.G2.Beta) }, ++ func(r io.Reader) (int64, error) { return readG1Points(r, c.G1.Tau[1:]) }, ++ func(r io.Reader) (int64, error) { return readG2Points(r, c.G2.Tau[1:]) }, ++ func(r io.Reader) (int64, error) { return readG1Points(r, c.G1.BetaTau) }, ++ func(r io.Reader) (int64, error) { return readG1Points(r, c.G1.AlphaTau) }, ++ } { ++ dn, readErr := read(reader) ++ n += dn ++ if readErr != nil { ++ return n, readErr ++ } ++ } ++ return n, nil ++} ++ ++func writeAll(writer io.Writer, data []byte) (int, error) { ++ written := 0 ++ for len(data) > 0 { ++ n, err := writer.Write(data) ++ written += n ++ data = data[n:] ++ if err != nil { ++ return written, err ++ } ++ if n == 0 { ++ return written, io.ErrShortWrite ++ } ++ } ++ return written, nil ++} ++ ++func writeG1Points(writer io.Writer, points []curve.G1Affine) (n int64, err error) { ++ for start := 0; start < len(points); start += srsCodecChunkSize { ++ end := min(start+srsCodecChunkSize, len(points)) ++ chunk := make([]byte, (end-start)*curve.SizeOfG1AffineCompressed) ++ utils.Parallelize(end-start, func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ encoded := points[start+i].Bytes() ++ copy(chunk[i*curve.SizeOfG1AffineCompressed:], encoded[:]) ++ } ++ }) ++ dn, writeErr := writeAll(writer, chunk) ++ n += int64(dn) ++ if writeErr != nil { ++ return n, writeErr ++ } ++ } ++ return n, nil ++} ++ ++func writeG2Points(writer io.Writer, points []curve.G2Affine) (n int64, err error) { ++ for start := 0; start < len(points); start += srsCodecChunkSize { ++ end := min(start+srsCodecChunkSize, len(points)) ++ chunk := make([]byte, (end-start)*curve.SizeOfG2AffineCompressed) ++ utils.Parallelize(end-start, func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ encoded := points[start+i].Bytes() ++ copy(chunk[i*curve.SizeOfG2AffineCompressed:], encoded[:]) ++ } ++ }) ++ dn, writeErr := writeAll(writer, chunk) ++ n += int64(dn) ++ if writeErr != nil { ++ return n, writeErr ++ } ++ } ++ return n, nil ++} ++ ++func writeG2Point(writer io.Writer, point *curve.G2Affine) (int64, error) { ++ encoded := point.Bytes() ++ n, err := writeAll(writer, encoded[:]) ++ return int64(n), err ++} ++ ++func readG1Points(reader io.Reader, points []curve.G1Affine) (n int64, err error) { ++ return readPointChunks(reader, len(points), curve.SizeOfG1AffineCompressed, func(start int, chunk []byte, decodeErrs []error) { ++ utils.Parallelize(len(decodeErrs), func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ _, decodeErrs[i] = points[start+i].SetBytes(chunk[i*curve.SizeOfG1AffineCompressed : (i+1)*curve.SizeOfG1AffineCompressed]) ++ } ++ }) ++ }) ++} ++ ++func readG2Points(reader io.Reader, points []curve.G2Affine) (n int64, err error) { ++ return readPointChunks(reader, len(points), curve.SizeOfG2AffineCompressed, func(start int, chunk []byte, decodeErrs []error) { ++ utils.Parallelize(len(decodeErrs), func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ _, decodeErrs[i] = points[start+i].SetBytes(chunk[i*curve.SizeOfG2AffineCompressed : (i+1)*curve.SizeOfG2AffineCompressed]) ++ } ++ }) ++ }) ++} ++ ++func readG2Point(reader io.Reader, point *curve.G2Affine) (int64, error) { ++ var encoded [curve.SizeOfG2AffineCompressed]byte ++ n, err := io.ReadFull(reader, encoded[:]) ++ if err != nil { ++ return int64(n), err ++ } ++ _, err = point.SetBytes(encoded[:]) ++ return int64(n), err ++} ++ ++func readPointChunks(reader io.Reader, count, pointSize int, decode func(int, []byte, []error)) (n int64, err error) { ++ for start := 0; start < count; start += srsCodecChunkSize { ++ end := min(start+srsCodecChunkSize, count) ++ chunk := make([]byte, (end-start)*pointSize) ++ dn, readErr := io.ReadFull(reader, chunk) ++ n += int64(dn) ++ if readErr != nil { ++ return n, readErr ++ } ++ ++ decodeErrs := make([]error, end-start) ++ decode(start, chunk, decodeErrs) ++ for _, decodeErr := range decodeErrs { ++ if decodeErr != nil { ++ return n, decodeErr ++ } + } + } +- return dec.BytesRead(), nil ++ return n, nil + } +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/marshal_parallel_test.go +@@ -0,0 +1,239 @@ ++package mpcsetup ++ ++import ( ++ "bytes" ++ "encoding/binary" ++ "io" ++ "reflect" ++ "testing" ++ ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++) ++ ++func writeCommonsSerial(c *SrsCommons, writer io.Writer) (int64, error) { ++ enc := curve.NewEncoder(writer) ++ if err := enc.Encode(uint64(len(c.G2.Tau))); err != nil { ++ return enc.BytesWritten(), err ++ } ++ values := []any{&c.G2.Beta} ++ for i := 1; i < len(c.G1.Tau); i++ { ++ values = append(values, &c.G1.Tau[i]) ++ } ++ for i := 1; i < len(c.G2.Tau); i++ { ++ values = append(values, &c.G2.Tau[i]) ++ } ++ for i := range c.G1.BetaTau { ++ values = append(values, &c.G1.BetaTau[i]) ++ } ++ for i := range c.G1.AlphaTau { ++ values = append(values, &c.G1.AlphaTau[i]) ++ } ++ for _, value := range values { ++ if err := enc.Encode(value); err != nil { ++ return enc.BytesWritten(), err ++ } ++ } ++ return enc.BytesWritten(), nil ++} ++ ++func readCommonsSerial(c *SrsCommons, reader io.Reader) (int64, error) { ++ dec := curve.NewDecoder(reader) ++ var n uint64 ++ if err := dec.Decode(&n); err != nil { ++ return dec.BytesRead(), err ++ } ++ c.setContributionsZero(n) ++ values := []any{&c.G2.Beta} ++ for i := 1; i < len(c.G1.Tau); i++ { ++ values = append(values, &c.G1.Tau[i]) ++ } ++ for i := 1; i < len(c.G2.Tau); i++ { ++ values = append(values, &c.G2.Tau[i]) ++ } ++ for i := range c.G1.BetaTau { ++ values = append(values, &c.G1.BetaTau[i]) ++ } ++ for i := range c.G1.AlphaTau { ++ values = append(values, &c.G1.AlphaTau[i]) ++ } ++ for _, value := range values { ++ if err := dec.Decode(value); err != nil { ++ return dec.BytesRead(), err ++ } ++ } ++ return dec.BytesRead(), nil ++} ++ ++func codecTestCommons(tb testing.TB, n uint64) SrsCommons { ++ tb.Helper() ++ var c SrsCommons ++ c.setContributionsZero(n) ++ _, _, g1, g2 := curve.Generators() ++ ++ nextG1 := g1 ++ fillG1 := func(points []curve.G1Affine) { ++ for i := range points { ++ nextG1.Add(&nextG1, &g1) ++ points[i].Set(&nextG1) ++ } ++ } ++ fillG1(c.G1.Tau[1:]) ++ fillG1(c.G1.BetaTau) ++ fillG1(c.G1.AlphaTau) ++ ++ nextG2 := g2 ++ nextG2.Add(&nextG2, &g2) ++ c.G2.Beta.Set(&nextG2) ++ for i := 1; i < len(c.G2.Tau); i++ { ++ nextG2.Add(&nextG2, &g2) ++ c.G2.Tau[i].Set(&nextG2) ++ } ++ ++ if n > 1 && c.G1.Tau[0].Equal(&c.G1.Tau[1]) { ++ tb.Fatal("deterministic codec fixture contains repeated Tau points") ++ } ++ if n > 0 && c.G1.BetaTau[0].Equal(&c.G1.AlphaTau[0]) { ++ tb.Fatal("deterministic codec fixture contains repeated parameter points") ++ } ++ return c ++} ++ ++func TestParallelCommonsCodecMatchesSerial(t *testing.T) { ++ for _, n := range []uint64{1, 2, 17, srsCodecChunkSize + 1} { ++ c := codecTestCommons(t, n) ++ var want, got bytes.Buffer ++ wantN, err := writeCommonsSerial(&c, &want) ++ if err != nil { ++ t.Fatal(err) ++ } ++ gotN, err := c.WriteTo(&got) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if gotN != wantN || !bytes.Equal(got.Bytes(), want.Bytes()) { ++ t.Fatalf("parallel encoding differs from serial encoding for N=%d", n) ++ } ++ ++ var decoded SrsCommons ++ readN, err := decoded.ReadFrom(bytes.NewReader(got.Bytes())) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if readN != gotN || !reflect.DeepEqual(decoded, c) { ++ t.Fatalf("parallel round trip differs for N=%d", n) ++ } ++ } ++} ++ ++func TestParallelCommonsDecoderRejectsMalformedPoint(t *testing.T) { ++ c := codecTestCommons(t, 2) ++ var encoded bytes.Buffer ++ if _, err := c.WriteTo(&encoded); err != nil { ++ t.Fatal(err) ++ } ++ data := encoded.Bytes() ++ firstG1 := 8 + curve.SizeOfG2AffineCompressed ++ data[firstG1] = 0xe0 // invalid point-encoding mask ++ var decoded SrsCommons ++ if _, err := decoded.ReadFrom(bytes.NewReader(data)); err == nil { ++ t.Fatal("malformed point was accepted") ++ } ++} ++ ++func TestParallelCommonsDecoderRejectsUncompressedPoints(t *testing.T) { ++ c := codecTestCommons(t, 2) ++ var encoded bytes.Buffer ++ enc := curve.NewEncoder(&encoded, curve.RawEncoding()) ++ if err := enc.Encode(uint64(len(c.G2.Tau))); err != nil { ++ t.Fatal(err) ++ } ++ values := []any{&c.G2.Beta} ++ for i := 1; i < len(c.G1.Tau); i++ { ++ values = append(values, &c.G1.Tau[i]) ++ } ++ for i := 1; i < len(c.G2.Tau); i++ { ++ values = append(values, &c.G2.Tau[i]) ++ } ++ for i := range c.G1.BetaTau { ++ values = append(values, &c.G1.BetaTau[i]) ++ } ++ for i := range c.G1.AlphaTau { ++ values = append(values, &c.G1.AlphaTau[i]) ++ } ++ for _, value := range values { ++ if err := enc.Encode(value); err != nil { ++ t.Fatal(err) ++ } ++ } ++ ++ var serial SrsCommons ++ if _, err := readCommonsSerial(&serial, bytes.NewReader(encoded.Bytes())); err != nil { ++ t.Fatalf("generic decoder rejected valid uncompressed fixture: %v", err) ++ } ++ var decoded SrsCommons ++ if _, err := decoded.ReadFrom(bytes.NewReader(encoded.Bytes())); err == nil { ++ t.Fatal("parallel compressed-only decoder accepted uncompressed Phase 1 points") ++ } ++} ++ ++func TestParallelCommonsDecoderCountsTruncatedInput(t *testing.T) { ++ var size [8]byte ++ binary.BigEndian.PutUint64(size[:], 1) ++ data := append(size[:], make([]byte, curve.SizeOfG2AffineCompressed-1)...) ++ var decoded SrsCommons ++ n, err := decoded.ReadFrom(bytes.NewReader(data)) ++ if err == nil { ++ t.Fatal("truncated input was accepted") ++ } ++ if n != int64(len(data)) { ++ t.Fatalf("read count %d, want %d", n, len(data)) ++ } ++} ++ ++func BenchmarkCommonsWriteSerial(b *testing.B) { ++ c := codecTestCommons(b, 1<<12) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ if _, err := writeCommonsSerial(&c, io.Discard); err != nil { ++ b.Fatal(err) ++ } ++ } ++} ++ ++func BenchmarkCommonsWriteParallel(b *testing.B) { ++ c := codecTestCommons(b, 1<<12) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ if _, err := c.WriteTo(io.Discard); err != nil { ++ b.Fatal(err) ++ } ++ } ++} ++ ++func benchmarkCommonsRead(b *testing.B, parallel bool) { ++ c := codecTestCommons(b, 1<<12) ++ var encoded bytes.Buffer ++ if _, err := writeCommonsSerial(&c, &encoded); err != nil { ++ b.Fatal(err) ++ } ++ data := encoded.Bytes() ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ var decoded SrsCommons ++ var err error ++ if parallel { ++ _, err = decoded.ReadFrom(bytes.NewReader(data)) ++ } else { ++ _, err = readCommonsSerial(&decoded, bytes.NewReader(data)) ++ } ++ if err != nil { ++ b.Fatal(err) ++ } ++ } ++} ++ ++func BenchmarkCommonsReadSerial(b *testing.B) { benchmarkCommonsRead(b, false) } ++func BenchmarkCommonsReadParallel(b *testing.B) { benchmarkCommonsRead(b, true) } diff --git a/experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch b/experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch new file mode 100644 index 0000000..96443cf --- /dev/null +++ b/experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch @@ -0,0 +1,194 @@ +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase1.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase1.go +@@ -16,6 +16,7 @@ + curve "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" + "github.com/consensys/gnark-crypto/ecc/bls12-381/mpcsetup" ++ "github.com/consensys/gnark/internal/utils" + ) + + // SrsCommons are the circuit-independent components of the Groth16 SRS, +@@ -102,9 +103,6 @@ + + // from the fourth argument on this just gives an opportunity to avoid recomputing some scalar multiplications + func (c *SrsCommons) update(tauUpdate, alphaUpdate, betaUpdate *fr.Element) { +- +- // TODO @gbotrel working with jacobian points here will help with perf. +- + // update α, β + var coeff big.Int + alphaUpdate.BigInt(&coeff) +@@ -113,35 +111,47 @@ + c.G1.BetaTau[0].ScalarMultiplication(&c.G1.BetaTau[0], &coeff) + c.G2.Beta.ScalarMultiplication(&c.G2.Beta, &coeff) + +- // update all values from 1 to N-1 +- tauPowI := *tauUpdate +- for i := 1; i < len(c.G2.Tau); i++ { +- tauPowI.BigInt(&coeff) +- +- c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) +- c.G2.Tau[i].ScalarMultiplication(&c.G2.Tau[i], &coeff) +- +- var tauPowIScaled fr.Element +- +- // let α₁ = α₀.α', τ₁ = τ₀.τ' +- // then α₁τ₁ⁱ = (α₀τ₀ⁱ)α'τ'ⁱ +- tauPowIScaled.Mul(&tauPowI, alphaUpdate) +- tauPowIScaled.BigInt(&coeff) +- c.G1.AlphaTau[i].ScalarMultiplication(&c.G1.AlphaTau[i], &coeff) +- +- // similarly for β +- tauPowIScaled.Mul(&tauPowI, betaUpdate) +- tauPowIScaled.BigInt(&coeff) +- c.G1.BetaTau[i].ScalarMultiplication(&c.G1.BetaTau[i], &coeff) ++ // Every worker owns a disjoint range and computes the first power it needs. ++ // Splitting the two ranges separately balances the more expensive first half ++ // (four scalar multiplications per index) independently from the second half. ++ utils.Parallelize(len(c.G2.Tau)-1, func(start, end int) { ++ start++ ++ end++ ++ updateTauRange(c, tauUpdate, alphaUpdate, betaUpdate, start, end, true) ++ }) ++ utils.Parallelize(len(c.G1.Tau)-len(c.G2.Tau), func(start, end int) { ++ start += len(c.G2.Tau) ++ end += len(c.G2.Tau) ++ updateTauRange(c, tauUpdate, alphaUpdate, betaUpdate, start, end, false) ++ }) ++} + +- tauPowI.Mul(&tauPowI, tauUpdate) +- } ++func updateTauRange(c *SrsCommons, tauUpdate, alphaUpdate, betaUpdate *fr.Element, start, end int, updateBothGroups bool) { ++ var exponent, coeff big.Int ++ exponent.SetUint64(uint64(start)) ++ var tauPowI fr.Element ++ tauPowI.Exp(*tauUpdate, &exponent) + +- // update the rest of [τⁱ]₁ +- for i := len(c.G2.Tau); i < len(c.G1.Tau); i++ { ++ for i := start; i < end; i++ { + tauPowI.BigInt(&coeff) + c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) + ++ if updateBothGroups { ++ c.G2.Tau[i].ScalarMultiplication(&c.G2.Tau[i], &coeff) ++ ++ var tauPowIScaled fr.Element ++ // let α₁ = α₀.α', τ₁ = τ₀.τ' ++ // then α₁τ₁ⁱ = (α₀τ₀ⁱ)α'τ'ⁱ ++ tauPowIScaled.Mul(&tauPowI, alphaUpdate) ++ tauPowIScaled.BigInt(&coeff) ++ c.G1.AlphaTau[i].ScalarMultiplication(&c.G1.AlphaTau[i], &coeff) ++ ++ // similarly for β ++ tauPowIScaled.Mul(&tauPowI, betaUpdate) ++ tauPowIScaled.BigInt(&coeff) ++ c.G1.BetaTau[i].ScalarMultiplication(&c.G1.BetaTau[i], &coeff) ++ } ++ + tauPowI.Mul(&tauPowI, tauUpdate) + } + } +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase1_parallel_test.go +@@ -0,0 +1,99 @@ ++package mpcsetup ++ ++import ( ++ "math/big" ++ "reflect" ++ "testing" ++ ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" ++) ++ ++func updateSerial(c *SrsCommons, tauUpdate, alphaUpdate, betaUpdate *fr.Element) { ++ var coeff big.Int ++ alphaUpdate.BigInt(&coeff) ++ c.G1.AlphaTau[0].ScalarMultiplication(&c.G1.AlphaTau[0], &coeff) ++ betaUpdate.BigInt(&coeff) ++ c.G1.BetaTau[0].ScalarMultiplication(&c.G1.BetaTau[0], &coeff) ++ c.G2.Beta.ScalarMultiplication(&c.G2.Beta, &coeff) ++ ++ tauPowI := *tauUpdate ++ for i := 1; i < len(c.G2.Tau); i++ { ++ tauPowI.BigInt(&coeff) ++ c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) ++ c.G2.Tau[i].ScalarMultiplication(&c.G2.Tau[i], &coeff) ++ ++ var scaled fr.Element ++ scaled.Mul(&tauPowI, alphaUpdate).BigInt(&coeff) ++ c.G1.AlphaTau[i].ScalarMultiplication(&c.G1.AlphaTau[i], &coeff) ++ scaled.Mul(&tauPowI, betaUpdate).BigInt(&coeff) ++ c.G1.BetaTau[i].ScalarMultiplication(&c.G1.BetaTau[i], &coeff) ++ tauPowI.Mul(&tauPowI, tauUpdate) ++ } ++ for i := len(c.G2.Tau); i < len(c.G1.Tau); i++ { ++ tauPowI.BigInt(&coeff) ++ c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) ++ tauPowI.Mul(&tauPowI, tauUpdate) ++ } ++} ++ ++func testCommons(n uint64) SrsCommons { ++ var c SrsCommons ++ c.setOne(n) ++ return c ++} ++ ++func TestParallelUpdateMatchesSerial(t *testing.T) { ++ var tau, alpha, beta fr.Element ++ tau.SetUint64(17) ++ alpha.SetUint64(23) ++ beta.SetUint64(29) ++ ++ for _, n := range []uint64{1, 2, 3, 17, 64} { ++ t.Run(new(big.Int).SetUint64(n).String(), func(t *testing.T) { ++ want := testCommons(n) ++ got := testCommons(n) ++ updateSerial(&want, &tau, &alpha, &beta) ++ got.update(&tau, &alpha, &beta) ++ if !reflect.DeepEqual(got, want) { ++ t.Fatal("parallel update differs from the serial implementation") ++ } ++ }) ++ } ++} ++ ++func benchmarkUpdate(b *testing.B, parallel bool) { ++ const n = 1 << 10 ++ var tau, alpha, beta fr.Element ++ tau.SetUint64(17) ++ alpha.SetUint64(23) ++ beta.SetUint64(29) ++ base := testCommons(n) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ c := cloneCommons(base) ++ if parallel { ++ c.update(&tau, &alpha, &beta) ++ } else { ++ updateSerial(&c, &tau, &alpha, &beta) ++ } ++ } ++} ++ ++func cloneCommons(c SrsCommons) SrsCommons { ++ cloneG1 := func(points []curve.G1Affine) []curve.G1Affine { ++ return append([]curve.G1Affine(nil), points...) ++ } ++ cloneG2 := func(points []curve.G2Affine) []curve.G2Affine { ++ return append([]curve.G2Affine(nil), points...) ++ } ++ c.G1.Tau = cloneG1(c.G1.Tau) ++ c.G1.AlphaTau = cloneG1(c.G1.AlphaTau) ++ c.G1.BetaTau = cloneG1(c.G1.BetaTau) ++ c.G2.Tau = cloneG2(c.G2.Tau) ++ return c ++} ++ ++func BenchmarkUpdateSerial(b *testing.B) { benchmarkUpdate(b, false) } ++func BenchmarkUpdateParallel(b *testing.B) { benchmarkUpdate(b, true) } diff --git a/experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch b/experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch new file mode 100644 index 0000000..1d3a82c --- /dev/null +++ b/experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch @@ -0,0 +1,640 @@ +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2.go +@@ -11,6 +11,7 @@ + "errors" + "fmt" + "math/big" ++ "runtime" + "slices" + + curve "github.com/consensys/gnark-crypto/ecc/bls12-381" +@@ -156,12 +157,30 @@ + // It involves no coin tosses. A verifier should + // simply rerun all the steps + func (p *Phase2) Initialize(r1cs *cs.R1CS, commons *SrsCommons) Phase2Evaluations { ++ return p.initialize(r1cs, commons, runtime.NumCPU()) ++} ++ ++const phase2ConstraintBatchSize = 16 * 1024 ++ ++type phase2WeightedTerm struct { ++ constraintIndex int ++ term constraint.Term ++} ++ ++type phase2WorkerTerms struct { ++ left, right, output []phase2WeightedTerm ++} ++ ++func (p *Phase2) initialize(r1cs *cs.R1CS, commons *SrsCommons, nbTasks int) Phase2Evaluations { + // TODO @Tabaie option to only compute the phase 2 info and not the evaluations, for a contributor + + n := len(commons.G1.AlphaTau) + if n < r1cs.GetNbConstraints() { + panic("Number of constraints is larger than expected") + } ++ if nbTasks < 1 { ++ nbTasks = 1 ++ } + + accumulateG1 := func(res *curve.G1Affine, t constraint.Term, value *curve.G1Affine) { + cID := t.CoeffID() +@@ -204,10 +223,10 @@ + } + + // Prepare Lagrange coefficients of [τ...]₁, [τ...]₂, [ατ...]₁, [βτ...]₁ +- coeffTau1 := lagrangeCoeffsG1(commons.G1.Tau, n) // [L_{ω⁰}(τ)]₁, [L_{ω¹}(τ)]₁, ... where ω is a primitive sizeᵗʰ root of unity +- coeffTau2 := lagrangeCoeffsG2(commons.G2.Tau, n) // [L_{ω⁰}(τ)]₂, [L_{ω¹}(τ)]₂, ... +- coeffAlphaTau1 := lagrangeCoeffsG1(commons.G1.AlphaTau, n) // [L_{ω⁰}(ατ)]₁, [L_{ω¹}(ατ)]₁, ... +- coeffBetaTau1 := lagrangeCoeffsG1(commons.G1.BetaTau, n) // [L_{ω⁰}(βτ)]₁, [L_{ω¹}(βτ)]₁, ... ++ coeffTau1 := lagrangeCoeffsG1WithTasks(commons.G1.Tau, n, nbTasks) // [L_{ω⁰}(τ)]₁, [L_{ω¹}(τ)]₁, ... where ω is a primitive sizeᵗʰ root of unity ++ coeffTau2 := lagrangeCoeffsG2WithTasks(commons.G2.Tau, n, nbTasks) // [L_{ω⁰}(τ)]₂, [L_{ω¹}(τ)]₂, ... ++ coeffAlphaTau1 := lagrangeCoeffsG1WithTasks(commons.G1.AlphaTau, n, nbTasks) // [L_{ω⁰}(ατ)]₁, [L_{ω¹}(ατ)]₁, ... ++ coeffBetaTau1 := lagrangeCoeffsG1WithTasks(commons.G1.BetaTau, n, nbTasks) // [L_{ω⁰}(βτ)]₁, [L_{ω¹}(βτ)]₁, ... + + nbInternal, nbSecret, nbPublic := r1cs.GetNbVariables() + nWires := nbInternal + nbSecret + nbPublic +@@ -221,30 +240,57 @@ + aB := make([]curve.G1Affine, nWires) + C := make([]curve.G1Affine, nWires) + ++ nbTasks = min(nbTasks, max(nWires, 1)) ++ workerTerms := make([]phase2WorkerTerms, nbTasks) ++ flushTerms := func() { ++ utils.Parallelize(nbTasks, func(start, end int) { ++ for worker := start; worker < end; worker++ { ++ for _, weighted := range workerTerms[worker].left { ++ t := weighted.term ++ wireID := t.WireID() ++ accumulateG1(&evals.G1.A[wireID], t, &coeffTau1[weighted.constraintIndex]) ++ accumulateG1(&bA[wireID], t, &coeffBetaTau1[weighted.constraintIndex]) ++ } ++ for _, weighted := range workerTerms[worker].right { ++ t := weighted.term ++ wireID := t.WireID() ++ accumulateG1(&evals.G1.B[wireID], t, &coeffTau1[weighted.constraintIndex]) ++ accumulateG2(&evals.G2.B[wireID], t, &coeffTau2[weighted.constraintIndex]) ++ accumulateG1(&aB[wireID], t, &coeffAlphaTau1[weighted.constraintIndex]) ++ } ++ for _, weighted := range workerTerms[worker].output { ++ t := weighted.term ++ wireID := t.WireID() ++ accumulateG1(&C[wireID], t, &coeffTau1[weighted.constraintIndex]) ++ } ++ workerTerms[worker].left = workerTerms[worker].left[:0] ++ workerTerms[worker].right = workerTerms[worker].right[:0] ++ workerTerms[worker].output = workerTerms[worker].output[:0] ++ } ++ }, nbTasks) ++ } ++ + i := 0 + it := r1cs.GetR1CIterator() + for c := it.Next(); c != nil; c = it.Next() { +- // each constraint is sparse, i.e. involves a small portion of all variables. +- // so we iterate over the variables involved and add the constraint's contribution +- // to every variable's A, B, and C values +- +- // A + for _, t := range c.L { +- accumulateG1(&evals.G1.A[t.WireID()], t, &coeffTau1[i]) +- accumulateG1(&bA[t.WireID()], t, &coeffBetaTau1[i]) ++ worker := t.WireID() % nbTasks ++ workerTerms[worker].left = append(workerTerms[worker].left, phase2WeightedTerm{i, t}) + } +- // B + for _, t := range c.R { +- accumulateG1(&evals.G1.B[t.WireID()], t, &coeffTau1[i]) +- accumulateG2(&evals.G2.B[t.WireID()], t, &coeffTau2[i]) +- accumulateG1(&aB[t.WireID()], t, &coeffAlphaTau1[i]) ++ worker := t.WireID() % nbTasks ++ workerTerms[worker].right = append(workerTerms[worker].right, phase2WeightedTerm{i, t}) + } +- // C + for _, t := range c.O { +- accumulateG1(&C[t.WireID()], t, &coeffTau1[i]) ++ worker := t.WireID() % nbTasks ++ workerTerms[worker].output = append(workerTerms[worker].output, phase2WeightedTerm{i, t}) + } + i++ ++ if i%phase2ConstraintBatchSize == 0 { ++ flushTerms() ++ } + } ++ flushTerms() + + // Prepare default contribution + _, _, g1, g2 := curve.Generators() +@@ -254,9 +300,11 @@ + // Build Z in PK as τⁱ(τⁿ - 1) = τ⁽ⁱ⁺ⁿ⁾ - τⁱ for i ∈ [0, n-2] + // τⁱ(τⁿ - 1) = τ⁽ⁱ⁺ⁿ⁾ - τⁱ for i ∈ [0, n-2] + p.Parameters.G1.Z = make([]curve.G1Affine, n) +- for i := range n - 1 { +- p.Parameters.G1.Z[i].Sub(&commons.G1.Tau[i+n], &commons.G1.Tau[i]) +- } ++ utils.Parallelize(n-1, func(start, end int) { ++ for i := start; i < end; i++ { ++ p.Parameters.G1.Z[i].Sub(&commons.G1.Tau[i+n], &commons.G1.Tau[i]) ++ } ++ }, nbTasks) + bitReverse(p.Parameters.G1.Z) + p.Parameters.G1.Z = p.Parameters.G1.Z[:n-1] + +@@ -280,11 +328,16 @@ + evals.G1.VKK = make([]curve.G1Affine, 0, nbPublic+len(commitments)) + committedIterator := internal.NewMergeIterator(commitments.GetPrivateCommitted()) + nbCommitmentsSeen := 0 ++ combined := make([]curve.G1Affine, nWires) ++ utils.Parallelize(nWires, func(start, end int) { ++ for j := start; j < end; j++ { ++ combined[j].Add(&bA[j], &aB[j]) ++ combined[j].Add(&combined[j], &C[j]) ++ } ++ }, nbTasks) + for j := 0; j < nWires; j++ { + // since as yet δ, γ = 1, the VKK and PKK are computed identically, as βA + αB + C +- var tmp curve.G1Affine +- tmp.Add(&bA[j], &aB[j]) +- tmp.Add(&tmp, &C[j]) ++ tmp := combined[j] + commitmentIndex := committedIterator.IndexIfNext(j) + isCommitment := nbCommitmentsSeen < len(commitments) && commitments[nbCommitmentsSeen].CommitmentIndex == j + if commitmentIndex != -1 { +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/lagrange.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/lagrange.go +@@ -19,10 +19,14 @@ + + // TODO use gnark-crypto for this op + func lagrangeCoeffsG1(powers []curve.G1Affine, size int) []curve.G1Affine { ++ return lagrangeCoeffsG1WithTasks(powers, size, runtime.NumCPU()) ++} ++ ++func lagrangeCoeffsG1WithTasks(powers []curve.G1Affine, size, nbTasks int) []curve.G1Affine { + coeffs := make([]curve.G1Affine, size) + copy(coeffs, powers[:size]) + domain := fft.NewDomain(uint64(size)) +- numCPU := uint64(runtime.NumCPU()) ++ numCPU := uint64(max(nbTasks, 1)) + maxSplits := bits.TrailingZeros64(ecc.NextPowerOfTwo(numCPU)) + + twiddlesInv, _ := domain.TwiddlesInv() +@@ -36,16 +40,20 @@ + for i := start; i < end; i++ { + coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) + } +- }) ++ }, nbTasks) + return coeffs + } + + // TODO use gnark-crypto for this op + func lagrangeCoeffsG2(powers []curve.G2Affine, size int) []curve.G2Affine { ++ return lagrangeCoeffsG2WithTasks(powers, size, runtime.NumCPU()) ++} ++ ++func lagrangeCoeffsG2WithTasks(powers []curve.G2Affine, size, nbTasks int) []curve.G2Affine { + coeffs := make([]curve.G2Affine, size) + copy(coeffs, powers[:size]) + domain := fft.NewDomain(uint64(size)) +- numCPU := uint64(runtime.NumCPU()) ++ numCPU := uint64(max(nbTasks, 1)) + maxSplits := bits.TrailingZeros64(ecc.NextPowerOfTwo(numCPU)) + + twiddlesInv, _ := domain.TwiddlesInv() +@@ -59,7 +67,7 @@ + for i := start; i < end; i++ { + coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) + } +- }) ++ }, nbTasks) + return coeffs + } + +@@ -145,12 +153,18 @@ + + butterflyG1(&a[0], &a[m]) + +- var twiddle big.Int +- for i := 1; i < m; i++ { +- butterflyG1(&a[i], &a[i+m]) +- twiddles[stage][i].BigInt(&twiddle) +- a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ stageTasks := 1 ++ if stage < maxSplits { ++ stageTasks = 1 << (maxSplits - stage) + } ++ utils.Parallelize(m-1, func(start, end int) { ++ var twiddle big.Int ++ for i := start + 1; i < end+1; i++ { ++ butterflyG1(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ }, stageTasks) + + if m == 1 { + return +@@ -183,12 +197,18 @@ + + butterflyG2(&a[0], &a[m]) + +- var twiddle big.Int +- for i := 1; i < m; i++ { +- butterflyG2(&a[i], &a[i+m]) +- twiddles[stage][i].BigInt(&twiddle) +- a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ stageTasks := 1 ++ if stage < maxSplits { ++ stageTasks = 1 << (maxSplits - stage) + } ++ utils.Parallelize(m-1, func(start, end int) { ++ var twiddle big.Int ++ for i := start + 1; i < end+1; i++ { ++ butterflyG2(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ }, stageTasks) + + if m == 1 { + return +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2_parallel_test.go +@@ -0,0 +1,137 @@ ++package mpcsetup ++ ++import ( ++ "fmt" ++ "math/big" ++ "reflect" ++ "runtime" ++ "testing" ++ ++ "github.com/consensys/gnark-crypto/ecc" ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" ++ cs "github.com/consensys/gnark/constraint/bls12-381" ++ "github.com/consensys/gnark/frontend" ++ "github.com/consensys/gnark/frontend/cs/r1cs" ++) ++ ++type phase2ParallelCircuit struct { ++ X frontend.Variable ++ Y frontend.Variable `gnark:",public"` ++ iterations int ++} ++ ++func (c *phase2ParallelCircuit) Define(api frontend.API) error { ++ value := c.X ++ for i := 0; i < c.iterations; i++ { ++ value = api.Mul(value, c.X) ++ value = api.Add(value, i+1) ++ } ++ api.AssertIsEqual(value, c.Y) ++ return nil ++} ++ ++func phase2DistinctCommons(tb testing.TB, n uint64) *SrsCommons { ++ tb.Helper() ++ var commons SrsCommons ++ commons.setContributionsZero(n) ++ _, _, g1, g2 := curve.Generators() ++ ++ var one, tau, alpha, beta fr.Element ++ one.SetOne() ++ tau.SetUint64(5) ++ alpha.SetUint64(7) ++ beta.SetUint64(11) ++ ++ fillG1Powers := func(points []curve.G1Affine, scale *fr.Element) { ++ power := one ++ var scalar fr.Element ++ var scalarBig big.Int ++ for i := range points { ++ scalar.Mul(&power, scale) ++ scalar.BigInt(&scalarBig) ++ points[i].ScalarMultiplication(&g1, &scalarBig) ++ power.Mul(&power, &tau) ++ } ++ } ++ fillG2Powers := func(points []curve.G2Affine) { ++ power := one ++ var scalarBig big.Int ++ for i := range points { ++ power.BigInt(&scalarBig) ++ points[i].ScalarMultiplication(&g2, &scalarBig) ++ power.Mul(&power, &tau) ++ } ++ } ++ ++ fillG1Powers(commons.G1.Tau, &one) ++ fillG1Powers(commons.G1.AlphaTau, &alpha) ++ fillG1Powers(commons.G1.BetaTau, &beta) ++ fillG2Powers(commons.G2.Tau) ++ var betaBig big.Int ++ beta.BigInt(&betaBig) ++ commons.G2.Beta.ScalarMultiplication(&g2, &betaBig) ++ ++ if n > 1 && (commons.G1.Tau[0].Equal(&commons.G1.Tau[1]) || commons.G2.Tau[0].Equal(&commons.G2.Tau[1])) { ++ tb.Fatal("deterministic SRS fixture contains repeated powers") ++ } ++ return &commons ++} ++ ++func phase2ParallelFixture(tb testing.TB, iterations int) (*cs.R1CS, *SrsCommons) { ++ tb.Helper() ++ compiled, err := frontend.Compile(curve.ID.ScalarField(), r1cs.NewBuilder, &phase2ParallelCircuit{iterations: iterations}) ++ if err != nil { ++ tb.Fatal(err) ++ } ++ constraints := compiled.(*cs.R1CS) ++ domainSize := ecc.NextPowerOfTwo(uint64(constraints.GetNbConstraints())) ++ return constraints, phase2DistinctCommons(tb, domainSize) ++} ++ ++func TestParallelPhase2InitializeMatchesUpstreamSerialReference(t *testing.T) { ++ constraints, commons := phase2ParallelFixture(t, 64) ++ lagrange := phase2SerialLagrangeG1(commons.G1.Tau, len(commons.G1.AlphaTau)) ++ nonInfinity := 0 ++ for i := range lagrange { ++ if !lagrange[i].IsInfinity() { ++ nonInfinity++ ++ } ++ } ++ if nonInfinity < len(lagrange)/2 { ++ t.Fatalf("deterministic SRS fixture collapsed to %d/%d non-infinity Lagrange coefficients", nonInfinity, len(lagrange)) ++ } ++ ++ var wantPhase2 Phase2 ++ wantEvals := initializePhase2SerialReference(&wantPhase2, constraints, commons) ++ for _, nbTasks := range []int{1, 4} { ++ t.Run(fmt.Sprintf("%d-workers", nbTasks), func(t *testing.T) { ++ var gotPhase2 Phase2 ++ gotEvals := gotPhase2.initialize(constraints, commons, nbTasks) ++ if !reflect.DeepEqual(gotEvals, wantEvals) { ++ t.Fatalf("%d-worker Phase 2 evaluations differ from upstream serial reference", nbTasks) ++ } ++ if !reflect.DeepEqual(gotPhase2, wantPhase2) { ++ t.Fatalf("%d-worker Phase 2 parameters differ from upstream serial reference", nbTasks) ++ } ++ }) ++ } ++} ++ ++func benchmarkPhase2Initialize(b *testing.B, nbTasks int) { ++ constraints, commons := phase2ParallelFixture(b, 4096) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ var phase2 Phase2 ++ phase2.initialize(constraints, commons, nbTasks) ++ } ++} ++ ++func BenchmarkPhase2InitializeSingleWorker(b *testing.B) { ++ benchmarkPhase2Initialize(b, 1) ++} ++ ++func BenchmarkPhase2InitializeParallel(b *testing.B) { ++ benchmarkPhase2Initialize(b, runtime.NumCPU()) ++} +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2_serial_reference_test.go +@@ -0,0 +1,237 @@ ++package mpcsetup ++ ++import ( ++ "math/big" ++ "slices" ++ ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr/fft" ++ cryptompcsetup "github.com/consensys/gnark-crypto/ecc/bls12-381/mpcsetup" ++ "github.com/consensys/gnark/backend/groth16/internal" ++ "github.com/consensys/gnark/constraint" ++ cs "github.com/consensys/gnark/constraint/bls12-381" ++) ++ ++// These helpers retain the upstream gnark v0.15.0 single-threaded algorithms ++// as an independent oracle for the parallel Phase 2 patch. ++func phase2SerialLagrangeG1(powers []curve.G1Affine, size int) []curve.G1Affine { ++ coeffs := make([]curve.G1Affine, size) ++ copy(coeffs, powers[:size]) ++ domain := fft.NewDomain(uint64(size)) ++ twiddlesInv, _ := domain.TwiddlesInv() ++ phase2SerialDIFG1(coeffs, twiddlesInv, 0) ++ bitReverse(coeffs) ++ ++ var invBigint big.Int ++ domain.CardinalityInv.BigInt(&invBigint) ++ for i := range coeffs { ++ coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) ++ } ++ return coeffs ++} ++ ++func phase2SerialLagrangeG2(powers []curve.G2Affine, size int) []curve.G2Affine { ++ coeffs := make([]curve.G2Affine, size) ++ copy(coeffs, powers[:size]) ++ domain := fft.NewDomain(uint64(size)) ++ twiddlesInv, _ := domain.TwiddlesInv() ++ phase2SerialDIFG2(coeffs, twiddlesInv, 0) ++ bitReverse(coeffs) ++ ++ var invBigint big.Int ++ domain.CardinalityInv.BigInt(&invBigint) ++ for i := range coeffs { ++ coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) ++ } ++ return coeffs ++} ++ ++func phase2SerialDIFG1(a []curve.G1Affine, twiddles [][]fr.Element, stage int) { ++ n := len(a) ++ if n == 1 { ++ return ++ } ++ if n == 8 { ++ kerDIF8G1(a, twiddles, stage) ++ return ++ } ++ m := n >> 1 ++ phase2SerialButterflyG1(&a[0], &a[m]) ++ var twiddle big.Int ++ for i := 1; i < m; i++ { ++ phase2SerialButterflyG1(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ if m == 1 { ++ return ++ } ++ phase2SerialDIFG1(a[:m], twiddles, stage+1) ++ phase2SerialDIFG1(a[m:], twiddles, stage+1) ++} ++ ++func phase2SerialDIFG2(a []curve.G2Affine, twiddles [][]fr.Element, stage int) { ++ n := len(a) ++ if n == 1 { ++ return ++ } ++ if n == 8 { ++ kerDIF8G2(a, twiddles, stage) ++ return ++ } ++ m := n >> 1 ++ phase2SerialButterflyG2(&a[0], &a[m]) ++ var twiddle big.Int ++ for i := 1; i < m; i++ { ++ phase2SerialButterflyG2(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ if m == 1 { ++ return ++ } ++ phase2SerialDIFG2(a[:m], twiddles, stage+1) ++ phase2SerialDIFG2(a[m:], twiddles, stage+1) ++} ++ ++func phase2SerialButterflyG1(a, b *curve.G1Affine) { ++ t := *a ++ a.Add(a, b) ++ b.Sub(&t, b) ++} ++ ++func phase2SerialButterflyG2(a, b *curve.G2Affine) { ++ t := *a ++ a.Add(a, b) ++ b.Sub(&t, b) ++} ++ ++func initializePhase2SerialReference(p *Phase2, r1cs *cs.R1CS, commons *SrsCommons) Phase2Evaluations { ++ n := len(commons.G1.AlphaTau) ++ if n < r1cs.GetNbConstraints() { ++ panic("Number of constraints is larger than expected") ++ } ++ ++ accumulateG1 := func(res *curve.G1Affine, term constraint.Term, value *curve.G1Affine) { ++ cID := term.CoeffID() ++ switch cID { ++ case constraint.CoeffIdZero: ++ return ++ case constraint.CoeffIdOne: ++ res.Add(res, value) ++ case constraint.CoeffIdMinusOne: ++ res.Sub(res, value) ++ case constraint.CoeffIdTwo: ++ res.Add(res, value).Add(res, value) ++ default: ++ var tmp curve.G1Affine ++ var coefficient big.Int ++ r1cs.Coefficients[cID].BigInt(&coefficient) ++ tmp.ScalarMultiplication(value, &coefficient) ++ res.Add(res, &tmp) ++ } ++ } ++ accumulateG2 := func(res *curve.G2Affine, term constraint.Term, value *curve.G2Affine) { ++ cID := term.CoeffID() ++ switch cID { ++ case constraint.CoeffIdZero: ++ return ++ case constraint.CoeffIdOne: ++ res.Add(res, value) ++ case constraint.CoeffIdMinusOne: ++ res.Sub(res, value) ++ case constraint.CoeffIdTwo: ++ res.Add(res, value).Add(res, value) ++ default: ++ var tmp curve.G2Affine ++ var coefficient big.Int ++ r1cs.Coefficients[cID].BigInt(&coefficient) ++ tmp.ScalarMultiplication(value, &coefficient) ++ res.Add(res, &tmp) ++ } ++ } ++ ++ coeffTau1 := phase2SerialLagrangeG1(commons.G1.Tau, n) ++ coeffTau2 := phase2SerialLagrangeG2(commons.G2.Tau, n) ++ coeffAlphaTau1 := phase2SerialLagrangeG1(commons.G1.AlphaTau, n) ++ coeffBetaTau1 := phase2SerialLagrangeG1(commons.G1.BetaTau, n) ++ ++ nbInternal, nbSecret, nbPublic := r1cs.GetNbVariables() ++ nWires := nbInternal + nbSecret + nbPublic ++ var evals Phase2Evaluations ++ commitmentInfo := r1cs.CommitmentInfo.(constraint.Groth16Commitments) ++ evals.PublicAndCommitmentCommitted = commitmentInfo.GetPublicAndCommitmentCommitted(commitmentInfo.CommitmentIndexes(), nbPublic) ++ evals.G1.A = make([]curve.G1Affine, nWires) ++ evals.G1.B = make([]curve.G1Affine, nWires) ++ evals.G2.B = make([]curve.G2Affine, nWires) ++ bA := make([]curve.G1Affine, nWires) ++ aB := make([]curve.G1Affine, nWires) ++ cValues := make([]curve.G1Affine, nWires) ++ ++ i := 0 ++ it := r1cs.GetR1CIterator() ++ for c := it.Next(); c != nil; c = it.Next() { ++ for _, term := range c.L { ++ accumulateG1(&evals.G1.A[term.WireID()], term, &coeffTau1[i]) ++ accumulateG1(&bA[term.WireID()], term, &coeffBetaTau1[i]) ++ } ++ for _, term := range c.R { ++ accumulateG1(&evals.G1.B[term.WireID()], term, &coeffTau1[i]) ++ accumulateG2(&evals.G2.B[term.WireID()], term, &coeffTau2[i]) ++ accumulateG1(&aB[term.WireID()], term, &coeffAlphaTau1[i]) ++ } ++ for _, term := range c.O { ++ accumulateG1(&cValues[term.WireID()], term, &coeffTau1[i]) ++ } ++ i++ ++ } ++ ++ _, _, g1, g2 := curve.Generators() ++ p.Parameters.G1.Delta = g1 ++ p.Parameters.G2.Delta = g2 ++ p.Parameters.G1.Z = make([]curve.G1Affine, n) ++ for i := 0; i < n-1; i++ { ++ p.Parameters.G1.Z[i].Sub(&commons.G1.Tau[i+n], &commons.G1.Tau[i]) ++ } ++ bitReverse(p.Parameters.G1.Z) ++ p.Parameters.G1.Z = p.Parameters.G1.Z[:n-1] ++ ++ commitments := r1cs.CommitmentInfo.(constraint.Groth16Commitments) ++ evals.G1.CKK = make([][]curve.G1Affine, len(commitments)) ++ p.Sigmas = make([]cryptompcsetup.UpdateProof, len(commitments)) ++ p.Parameters.G1.SigmaCKK = make([][]curve.G1Affine, len(commitments)) ++ p.Parameters.G2.Sigma = make([]curve.G2Affine, len(commitments)) ++ for j := range commitments { ++ evals.G1.CKK[j] = make([]curve.G1Affine, 0, len(commitments[j].PrivateCommitted)) ++ p.Parameters.G2.Sigma[j] = g2 ++ } ++ ++ nbCommitted := internal.NbElements(commitments.GetPrivateCommitted()) ++ p.Parameters.G1.PKK = make([]curve.G1Affine, 0, nbInternal+nbSecret-nbCommitted-len(commitments)) ++ evals.G1.VKK = make([]curve.G1Affine, 0, nbPublic+len(commitments)) ++ committedIterator := internal.NewMergeIterator(commitments.GetPrivateCommitted()) ++ nbCommitmentsSeen := 0 ++ for j := 0; j < nWires; j++ { ++ var tmp curve.G1Affine ++ tmp.Add(&bA[j], &aB[j]) ++ tmp.Add(&tmp, &cValues[j]) ++ commitmentIndex := committedIterator.IndexIfNext(j) ++ isCommitment := nbCommitmentsSeen < len(commitments) && commitments[nbCommitmentsSeen].CommitmentIndex == j ++ if commitmentIndex != -1 { ++ evals.G1.CKK[commitmentIndex] = append(evals.G1.CKK[commitmentIndex], tmp) ++ } else if j < nbPublic || isCommitment { ++ evals.G1.VKK = append(evals.G1.VKK, tmp) ++ } else { ++ p.Parameters.G1.PKK = append(p.Parameters.G1.PKK, tmp) ++ } ++ if isCommitment { ++ nbCommitmentsSeen++ ++ } ++ } ++ for j := range commitments { ++ p.Parameters.G1.SigmaCKK[j] = slices.Clone(evals.G1.CKK[j]) ++ } ++ p.Challenge = nil ++ return evals ++} diff --git a/scripts/bootstrap-vendor.sh b/scripts/bootstrap-vendor.sh index 44558ab..3eb7626 100644 --- a/scripts/bootstrap-vendor.sh +++ b/scripts/bootstrap-vendor.sh @@ -2,7 +2,8 @@ # Regenerates vendor/ from go.mod and applies the reviewed browser-prover patches # (gnark ProveStream/MSM seam, opt-W2 domain decoding, opt-W3 CCS release, and # opt-W1 dispatch-before-FFT scheduling/yields, opt-W6 scoped computeH -# coset-table reuse and opt-C8 constant byte-operation folding). +# coset-table reuse, opt-C8 constant byte-operation folding, and the native +# MPC ceremony Phase 1/Phase 2 parallel hot paths). # vendor/ is # gitignored; this script is the ONLY supported way to (re)create it. # A plain `go mod vendor` produces a tree WITHOUT the streaming prover and @@ -19,9 +20,12 @@ PATCHES=( experiments/wasm-prover/patches/domain-read-no-precompute.patch experiments/wasm-prover/patches/release-ccs-after-solve.patch experiments/wasm-prover/patches/dispatch-before-fft.patch - experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch - experiments/wasm-prover/patches/uints-constant-fold.patch - experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch + experiments/wasm-prover/patches/uints-constant-fold.patch + experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch + experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch ) for patch in "${PATCHES[@]}"; do diff --git a/scripts/check-vendor-drift.sh b/scripts/check-vendor-drift.sh index 8401c2c..a04dd41 100755 --- a/scripts/check-vendor-drift.sh +++ b/scripts/check-vendor-drift.sh @@ -2,8 +2,8 @@ # Verifies that vendor/ is exactly `go mod vendor` output plus # the reviewed patches under experiments/wasm-prover/patches. The vendored # dependencies contain ProveStream/MSM plus opt-W2 domain-decoding, opt-W3 -# CCS-release, opt-W1 scheduling/yield, opt-W6 computeH table-lifetime, and -# opt-C8 constant byte-operation folding seams; +# CCS-release, opt-W1 scheduling/yield, opt-W6 computeH table-lifetime, +# opt-C8 constant byte-operation folding, and native MPC ceremony parallelism; # regenerating vendor/ without this check in place silently deletes the prover. # # Fails (exit 1) on any drift in either direction: an unmirrored vendor edit, @@ -16,9 +16,12 @@ PATCHES=( experiments/wasm-prover/patches/domain-read-no-precompute.patch experiments/wasm-prover/patches/release-ccs-after-solve.patch experiments/wasm-prover/patches/dispatch-before-fft.patch - experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch - experiments/wasm-prover/patches/uints-constant-fold.patch - experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch + experiments/wasm-prover/patches/uints-constant-fold.patch + experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch + experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch ) for patch in "${PATCHES[@]}"; do diff --git a/scripts/generate-go-sbom/main.go b/scripts/generate-go-sbom/main.go index c74e846..02e5983 100644 --- a/scripts/generate-go-sbom/main.go +++ b/scripts/generate-go-sbom/main.go @@ -30,6 +30,9 @@ var gnarkPatchPaths = []string{ "experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch", "experiments/wasm-prover/patches/uints-constant-fold.patch", "experiments/wasm-prover/patches/computeh-parallel-transforms.patch", + "experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch", + "experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch", + "experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch", } type bom struct { diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index 9c640d4..2cffa99 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -70,6 +70,9 @@ var ( "computeh-scoped-coset-tables.patch", "uints-constant-fold.patch", "computeh-parallel-transforms.patch", + "mpc-phase1-parallel-update.patch", + "mpc-phase1-parallel-codec.patch", + "mpc-phase2-parallel-initialize.patch", } ) From 14600b006f2f7810af762bcd3556e36cbcd7e5b9 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 08:37:50 +0000 Subject: [PATCH 29/42] fix(mpc): harden rehearsal finalization recovery --- scripts/mpc-finalization-evidence/main.go | 10 +++++++ .../mpc-finalization-evidence/main_test.go | 28 +++++++++++++++++++ scripts/run-mpc-k21-local-rehearsal.sh | 1 + 3 files changed, 39 insertions(+) create mode 100644 scripts/mpc-finalization-evidence/main_test.go diff --git a/scripts/mpc-finalization-evidence/main.go b/scripts/mpc-finalization-evidence/main.go index e702336..a84e7e7 100644 --- a/scripts/mpc-finalization-evidence/main.go +++ b/scripts/mpc-finalization-evidence/main.go @@ -11,9 +11,12 @@ import ( "errors" "flag" "fmt" + "io" "os" "path/filepath" + "github.com/consensys/gnark/logger" + "proof-tool/internal/circuit/ownership" "proof-tool/internal/circuit/ownershipdest" "proof-tool/internal/mpcceremony" @@ -43,12 +46,19 @@ const ( var goldenPath = ownership.Path{Account: 0, Role: 0, Index: 0} func main() { + // gnark defaults its global logger to stdout. Keep stdout exclusively for + // the helper's single JSON result so the ceremony runner can parse it. + configureLibraryLogging(os.Stderr) if err := run(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } +func configureLibraryLogging(stderr io.Writer) { + logger.SetOutput(stderr) +} + func run() error { fs := flag.NewFlagSet("mpc-finalization-evidence", flag.ContinueOnError) keysDir := fs.String("keys-dir", "", "preliminary final-key directory from mpc-ceremony finalize prepare") diff --git a/scripts/mpc-finalization-evidence/main_test.go b/scripts/mpc-finalization-evidence/main_test.go new file mode 100644 index 0000000..1344c46 --- /dev/null +++ b/scripts/mpc-finalization-evidence/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + gnarklogger "github.com/consensys/gnark/logger" + "github.com/rs/zerolog" +) + +func TestConfigureLibraryLoggingKeepsDiagnosticsOffStdout(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + gnarklogger.Set(zerolog.New(&stdout)) + t.Cleanup(gnarklogger.Disable) + + configureLibraryLogging(&stderr) + diagnosticLogger := gnarklogger.Logger() + diagnosticLogger.Debug().Msg("gnark diagnostic") + + if stdout.Len() != 0 { + t.Fatalf("gnark wrote %q to stdout", stdout.String()) + } + if !strings.Contains(stderr.String(), "gnark diagnostic") { + t.Fatalf("gnark diagnostic missing from stderr: %q", stderr.String()) + } +} diff --git a/scripts/run-mpc-k21-local-rehearsal.sh b/scripts/run-mpc-k21-local-rehearsal.sh index 21509e0..71a77ee 100755 --- a/scripts/run-mpc-k21-local-rehearsal.sh +++ b/scripts/run-mpc-k21-local-rehearsal.sh @@ -2035,6 +2035,7 @@ case "$STAGE" in --published-at "$PUBLISHED_AT" \ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \ --transcript-dir "$TRANSCRIPT" + write_state "$STATE_DIR/phase2-published-epoch.txt" "$PUBLISHED_EPOCH" PREPARED_EPOCH=$(step_epoch finalize-prepare "$((PUBLISHED_EPOCH + 1))") run_step finalize-prepare \ finalize prepare \ From 44aa96c5904b5fce0113fae1e4bca8b778f7cb47 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 10:04:57 +0000 Subject: [PATCH 30/42] ci(mpc): verify patched Relay integration --- .github/workflows/ci.yml | 6 ++++++ .github/workflows/mpc-ceremony-release-validation.yml | 11 ++++++++--- 2 files changed, 14 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6779f8c..c00e299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,12 @@ jobs: - name: Test run: go test -timeout 15m ./... + - name: Race-sensitive MPC parallel paths + run: | + go test -race -count=1 \ + github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup + go test -race -count=1 ./internal/mpcceremony + - name: WASM prover builds run: | GOOS=js GOARCH=wasm go build -o /dev/null ./cmd/wasm-prover diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml index 974f347..438db50 100644 --- a/.github/workflows/mpc-ceremony-release-validation.yml +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -15,7 +15,7 @@ concurrency: env: # Update this only after reviewing the Relay change and rerunning this gate. - RELAY_COMMIT: c0ccd19f884d6cb355372be95dd159405c3bf368 + RELAY_COMMIT: 199dbce047af852896b0027457eb3da82b758fcd jobs: rehearsal-reproducibility: @@ -86,7 +86,7 @@ jobs: name: Relay CLI compatibility (pinned commit) needs: rehearsal-reproducibility runs-on: ubuntu-latest - timeout-minutes: 20 + timeout-minutes: 35 steps: - name: Check out proof-tool uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 @@ -108,12 +108,17 @@ jobs: go-version-file: proof-tool/go.mod cache: false - - name: Exercise the proof-tool CLI boundary + - name: Bootstrap patched proof-tool vendor tree + working-directory: proof-tool + run: bash scripts/bootstrap-vendor.sh + + - name: Exercise the full proof-tool CLI boundary shell: bash run: | test "$(git -C relay rev-parse HEAD)" = "$RELAY_COMMIT" cd relay RELAY_PROOF_TOOL_DIR="$GITHUB_WORKSPACE/proof-tool" \ + RELAY_PROOF_TOOL_FULL=1 \ go test ./cmd/relay \ -run '^TestProofToolCompatibility$' \ -count=1 \ From 065cb7928782741165822a0c47e5cf0dc73aef4e Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 10:08:14 +0000 Subject: [PATCH 31/42] docs: remove local MPC ceremony runbook --- docs/mpc-ceremony-local-runbook.md | 259 ----------------------------- 1 file changed, 259 deletions(-) delete mode 100644 docs/mpc-ceremony-local-runbook.md diff --git a/docs/mpc-ceremony-local-runbook.md b/docs/mpc-ceremony-local-runbook.md deleted file mode 100644 index 9cfebd6..0000000 --- a/docs/mpc-ceremony-local-runbook.md +++ /dev/null @@ -1,259 +0,0 @@ -# MPC Ceremony — Local Runbook - -Everything below was executed against the working tree at `ba065e6` and the -outputs are the real ones, not illustrative. - -## Scope - -This is an orientation and rehearsal runbook: how to build the tool, stand up a -ceremony on one machine, and read what comes out. It is **not** a production -procedure. - -The production procedure is `docs/mpc-ceremony-runbook.md` (1,590 lines), which -is currently absent from `main`; it was removed by a history-filtering rewrite. -It survives in `refs/pull/34/head` of `Anastasia-Labs/proof-tool` at commit -`fd8516e`. Anything about enrollment, custody, witnessing, mirrors, beacon -selection, or release gates comes from that document, not this one. - -Same-host identities prove nothing about participant independence. A rehearsal -transcript is never mainnet key material. - -## The two roots of trust - -Every other file in a ceremony is derived and self-authenticating. Exactly two -things must reach you through channels you already trust. - -**1. The coordinator public key.** `coordinator-public-key.hex` decides whether a -signature counts. Take it from the same bundle as the signature it verifies and -you have proven only that the bundle agrees with itself — which any forger can -arrange. It must arrive over an independent authenticated channel. - -**2. The binary.** `SoftwareBinding` in the definition pins the tool digest, -source commit, and dependency versions; `VerifyRunningSoftware` refuses to -proceed on a mismatch. So the binary is a trust input too: built from a verified -signed tag, reproduced in two independent environments, hashes published -separately. `scripts/build-mpc-ceremony-release.sh` and -`scripts/verify-mpc-ceremony-reproducible.sh` do this for production. Maintainers -publish the directly downloadable binary and its full verification package by -following `docs/mpc-ceremony-release.md`. - -Everything else — `ceremony.json`, `ceremony.sig`, chains, contributions, -closures — may travel over untrusted transport. Tampering makes verification -fail rather than succeed. - -## Trust paths - -Nearly every subcommand takes the same three flags, which map to -`mpcceremony.TrustPaths` (`internal/mpcceremony/workflow.go:46`): - - --ceremony ceremony.json - --ceremony-signature ceremony.sig - --coordinator-public-key-file coordinator-public-key.hex - -All three are mandatory (`workflow.go:180-184`). `LoadSignedDefinition` turns -them into a `TrustedCeremony`, and every downstream check validates against that -rather than against loose files. The third path exists specifically so the trust -anchor is supplied from outside the bundle. The code cannot tell whether you -honoured that; only your process can. - -## Prerequisites - -Go 1.26.5 exactly, per `go.mod` and the pinned `ProductionGoVersion` in -`internal/mpcceremony/model.go`. A user-local install is fine: - - export PATH="$HOME/.local/go/bin:$PATH" - go version # go1.26.5 linux/amd64 - -**Build with `go build`, never `go run`.** `go run` does not embed VCS metadata, -and the binary refuses to start without it: - - running executable is missing vcs build setting - -`software.go:172-205` requires `vcs`, `vcs.revision` and `vcs.modified`. -`vcs.revision` becomes the ceremony's `source_commit`, which every contribution -attestation must match; `vcs.modified` must be `false` for production, so a -dirty checkout is refused outright. Inspect any binary with -`go version -m ./dist/mpc-ceremony`. - -## Quick start - - bash scripts/mpc-demo-init.sh /tmp/mpcdemo 3 - -That wrapper does the three steps below and refuses to reuse an existing root. -The manual form follows, because the wrapper hides the parts worth understanding. - -### 1. Build - - go build -o dist/mpc-ceremony ./cmd/mpc-ceremony - ./dist/mpc-ceremony help - -### 2. Generate rehearsal identities and canonical config - - go run ./scripts/mpc-rehearsal-config --out-dir /tmp/mpcdemo --participants 3 - -Writes `config/{participants,policy,environment}.json` plus Ed25519 keypairs for -eleven identities at three participants: coordinator, release signer, two -auditors, three participants, two public witnesses, two mirror operators. - -These config files are **canonical JSON**, not ordinary JSON. The decoder rejects -unknown fields, duplicate fields, reordered fields, pretty printing, extra -whitespace, trailing data, and a trailing newline. Do not hand-edit them and do -not round-trip them through `jq -S`; alphabetical key sorting changes the schema -order and the file stops parsing. Generate them with a program that calls -`MarshalCanonical`. - -### 3. Initialize - - D=/tmp/mpcdemo - ./dist/mpc-ceremony --format json init \ - --key-version ownership-destination-v2 \ - --participants "$D/config/participants.json" \ - --policy "$D/config/policy.json" \ - --coordinator-key-id coordinator-key \ - --coordinator-signing-key "$D/keys/coordinator.ed25519.private.hex" \ - --created-at 2026-08-11T00:00:00Z \ - --mode rehearsal \ - --out-dir "$D/public" - -`--coordinator-key-id` must equal the `key_id` inside `participants.json`. It is -not a name you choose. Read it back rather than guessing: - - python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["coordinator"]["key_id"])' \ - "$D/config/participants.json" - -Expect several minutes; `init` compiles the K=21 circuit. Observed output: - - {"level":"info","message":"compiling circuit"} - {"nbSecret":157,"nbPublic":1,"message":"parsed circuit inputs"} - {"nbConstraints":1791413,"message":"building constraint builder"} - {"schema":"proof-tool-mpc-command-result-v1","ok":true,"command":"init", - "ceremony_id":"sha256:965b04d8...520e", ... } - -## The seven artifacts - - 4608 ceremony.json - 434 ceremony.sig - 65 coordinator-public-key.hex - 129448055 ownership-destination.ccs - 490 phase1/chain-0000.json - 434 phase1/chain-0000.sig - 603980121 phase1/genesis.bin - -**`ceremony.json`** — the signed root document. Its `ceremony_id` is a -domain-tagged SHA-256 over its own canonical bytes, so the file names itself. -Contains the circuit binding (1,791,413 constraints, domain 2,097,152 = 2^21, the -R1CS digest), the pinned software stack, the roster, per-phase policies, and the -drand beacon policy. Also a `session_nonce_hex` so two ceremonies with identical -inputs still receive distinct IDs. - -**`ceremony.sig`** — detached Ed25519 signature over the exact bytes of -`ceremony.json`. Carries `signed_sha256`, so the signature names what it covers, -plus `key_id` and `public_key_fingerprint`. - -**`coordinator-public-key.hex`** — the raw 32-byte public key in hex. Trust root; -distribute out of band. - -**`ownership-destination.ccs`** — the compiled constraint system. Makes the -ceremony circuit-specific: Phase 2 is built from it, and its digest is pinned in -`ceremony.json`, so a different circuit is a different ceremony. - -**`phase1/genesis.bin`** — the starting powers-of-tau state, 576 MiB. The first -432 bytes are three empty update proofs (tau, alpha, beta); the real ladder -begins at offset 432 with a length prefix of `0x200000` = 2,097,152. Points are -compressed, and `0xc0` in a leading byte means "compressed, point at infinity". - -**`phase1/chain-0000.json`** — the empty chain: `"records": []`, plus `phase_id` -and the genesis `ArtifactRef` pinning that 576 MiB file by both digests and its -size. This is the head the first participant contributes on top of. - -**`phase1/chain-0000.sig`** — coordinator signature over that chain document. - -Note the split: two files hold all 705 MB of data, five hold all the authority in -about 6 KB. The large files are inert until a signed record names them by digest. - -## Verifying what you got - -The signature names its own key and its own payload. Both bindings should check -out: - - python3 - <<'EOF' - import hashlib, json - D = "/tmp/mpcdemo/public" - pk = open(f"{D}/coordinator-public-key.hex").read().strip() - sig = json.load(open(f"{D}/ceremony.sig")) - print("key fingerprint :", "sha256:" + hashlib.sha256(bytes.fromhex(pk)).hexdigest()) - print("claimed in sig :", sig["public_key_fingerprint"]) - print("signed_sha256 :", sig["signed_sha256"]) - print("actual of json :", "sha256:" + hashlib.sha256(open(f"{D}/ceremony.json","rb").read()).hexdigest()) - EOF - -This proves internal consistency only. It becomes meaningful when the public key -came from an independent channel. - -## Gotchas encountered - -- `go run` fails with `missing vcs build setting`. Use `go build`. -- `--coordinator-key-id` must match `participants.json`. A wrong value produces - a redacted error that blanks your input but leaves the correct value visible, - because that came from a file rather than argv. -- `scripts/mpc-demo-init.sh` refuses an existing root. Use a fresh path. -- The full rehearsal harness refuses to start below its capacity floors — 100 GiB - free and 16 GiB available RAM by default. Check with - `scripts/check-mpc-k21-capacity.sh`, override via `MPC_K21_MIN_*` env vars. -- Config files are canonical JSON. Editing them by hand breaks parsing. - -## Beyond init - -The next step is `phase1 contribute` for the first scheduled participant, which -replays the entire accepted chain before sampling entropy. At K=21 with three -participants that is gigabytes of I/O and hours of verification. Replay -progress is reported on stderr so running can be told apart from hung. - -For a staged, resumable local run through the whole lifecycle, use the real -harness instead of driving the CLI by hand: - - scripts/run-mpc-k21-local-rehearsal.sh prepare "$FRESH_ROOT" ./dist/mpc-ceremony 5 - scripts/run-mpc-k21-local-rehearsal.sh phase1-contribute "$FRESH_ROOT" ./dist/mpc-ceremony - scripts/run-mpc-k21-local-rehearsal.sh phase1-close "$FRESH_ROOT" ./dist/mpc-ceremony FUTURE_ROUND - ... - -It never fetches a beacon. The operator closes each phase on a future drand -round, publicly witnesses the closure, waits for that round, obtains the exact -raw response independently, and resumes. That sequencing is the security -property, not a formality: see the 2026-07-24 closure-timing incident recorded in -`docs/mpc-production-readiness.md`. - -## Beacon precedent in other ceremonies - -How the drand-quicknet-with-future-round design compares to other trusted-setup -implementations (surveyed 2026-08-11): - -- **Celo snark-setup-operator (Plumo)** — yes, drand mainnet, pre-announced - future round (923709, ~June 8 2021). `verify_transcript --apply-beacon` seeds - an RNG from the 32-byte beacon hash, runs an actual contribution, then - re-verifies it against the transcript - ([verify_transcript.rs](https://github.com/celo-org/snark-setup-operator/blob/master/src/bin/verify_transcript.rs), - [celo-bls-snark-rs #220](https://github.com/celo-org/celo-bls-snark-rs/issues/220)). - Mechanically the closest precedent to this design. -- **Perpetual Powers of Tau** — yes, applied per phase-2 branch-off rather than - once: announce a future Ethereum beacon-chain slot, take its RANDAO reveal, - apply via `snarkjs powersoftau beacon … 31` (2^31 hash iterations) - ([prepare-phase-2.md](https://github.com/privacy-ethereum/perpetualpowersoftau/blob/master/prepare-phase-2.md)). - The doc itself notes "experts differ as to whether the beacon step adds any - security" but snarkjs requires it. -- **p0tion (PSE)** — yes at finalization, but weakest: the coordinator types a - beacon value into a prompt, which is SHA-256'd and applied via `zKey.beacon` - with only 2^10 iterations; no drand, block hash, or future-round binding - anywhere in the repo - ([finalize.ts](https://github.com/privacy-ethereum/p0tion/blob/main/packages/phase2cli/src/commands/finalize.ts), - [prompts.ts:705](https://github.com/privacy-ethereum/p0tion/blob/main/packages/phase2cli/src/lib/prompts.ts)). - -The pattern comes from Zcash's 2018 Powers of Tau — 2^42 SHA-256 iterations over -the hash of Bitcoin block 514200, pre-announced -([attestation 0088](https://github.com/ZcashFoundation/powersoftau-attestations/tree/master/0088)). -The "beacon is unnecessary" claim traces to the Snarky Ceremonies paper -([eprint 2021/219](https://eprint.iacr.org/2021/219.pdf), -Kohlweiss/Maller/Siim/Volkhov, Asiacrypt 2021), which proved Groth16 ceremony -security without a beacon — yet all three implementations above still apply one -as defense-in-depth. This project's drand-quicknet-with-future-round design is -in line with the field and stricter than p0tion, roughly matching Plumo. From bf8cc7b858700e6048ace5fc916f6fff50d05430 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 10:25:38 +0000 Subject: [PATCH 32/42] docs: remove MPC ceremony reference documents --- docs/mpc-ceremony-release.md | 170 ------------------------- docs/mpc-ceremony-security-defenses.md | 169 ------------------------ 2 files changed, 339 deletions(-) delete mode 100644 docs/mpc-ceremony-release.md delete mode 100644 docs/mpc-ceremony-security-defenses.md diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md deleted file mode 100644 index 353f7ec..0000000 --- a/docs/mpc-ceremony-release.md +++ /dev/null @@ -1,170 +0,0 @@ -# Publishing `mpc-ceremony` - -This is the maintainer procedure for publishing the Linux/amd64 -`mpc-ceremony` binary and its complete verification package. Ceremony operators -normally download and verify these assets; they do not need the release build -environment or its private signing key. - -The Go module remains `proof-tool`. Relay communicates with `mpc-ceremony` -through its versioned CLI output, so publishing does not require a module-path -migration or an importable Go package. - -## Release assets - -Every GitHub release must contain both: - -- `mpc-ceremony`, the directly downloadable Linux/amd64 executable; and -- `mpc-ceremony--linux-amd64.tar`, the complete directory produced by - `scripts/build-mpc-ceremony-release.sh`, including checksums, SBOMs, source - and toolchain metadata, and the package manifest. - -Publishing `checksums.sha256` separately is recommended for convenience. The -authenticated release announcement must independently state the repository, -tag, source commit, binary SHA-256, package SHA-256, release mode, and—only for -production—the approved tag-signer and build-signing public-key fingerprints. -A checksum hosted beside a binary detects transfer corruption but is not an -independent trust channel. - -## Required release gates - -Before selecting a release commit: - -1. Merge all approved security fixes. -2. Require the `MPC ceremony release validation` workflow to pass. It rebuilds - the patched vendor tree, creates two unsigned rehearsal packages, verifies - that they are byte-identical, confirms that no production signatures exist, - and exercises the CLI against the exact Relay commit pinned in the workflow. -3. Review any change to `RELAY_COMMIT`; a moving branch or tag is not an - acceptable compatibility input. -4. Confirm `go.mod` still declares `module proof-tool`. - -The workflow can also be rerun from the Actions tab with **Run workflow**. CI -rehearsals are unsigned and are never production releases. - -## Publish a test release - -A test release proves the download path without using either production signing -key. Its tag and GitHub release must say `rehearsal`, and the release must be a -prerelease. - -Start from a clean ordinary clone, not a linked worktree, so Go can embed the -exact VCS revision: - - TEST_TAG=mpc-ceremony-rehearsal-v0.0.0-YYYYMMDD.N - git fetch origin --tags - git checkout --detach origin/main - test -z "$(git status --porcelain)" - test "$(sed -n 's/^module //p' go.mod)" = proof-tool - bash scripts/bootstrap-vendor.sh - mkdir -p /tmp/mpc-release-a-parent /tmp/mpc-release-b-parent - scripts/build-mpc-ceremony-release.sh \ - --mode rehearsal \ - --out-dir /tmp/mpc-release-a-parent/release - scripts/build-mpc-ceremony-release.sh \ - --mode rehearsal \ - --out-dir /tmp/mpc-release-b-parent/release - RELEASE_COMMIT=$(git rev-parse HEAD) - scripts/verify-mpc-ceremony-reproducible.sh \ - --mode rehearsal \ - --expected-commit "$RELEASE_COMMIT" \ - --expected-tag none \ - --tag-signer-fingerprint none \ - --trusted-build-public-key-file none \ - /tmp/mpc-release-a-parent/release \ - /tmp/mpc-release-b-parent/release - test ! -e /tmp/mpc-release-a-parent/release/build-package-manifest.sig - test ! -e /tmp/mpc-release-a-parent/release/build-package-manifest-public-key.hex - -Create a deterministic full-package archive and its separate checksums: - - RELEASE_DIR=/tmp/mpc-release-a-parent/release - RELEASE_EPOCH=$(<"$RELEASE_DIR/source-date-epoch.txt") - PACKAGE=/tmp/mpc-ceremony-$TEST_TAG-linux-amd64.tar - tar --sort=name --format=gnu --owner=0 --group=0 --numeric-owner \ - --mtime="@$RELEASE_EPOCH" \ - -C "$(dirname "$RELEASE_DIR")" \ - -cf "$PACKAGE" "$(basename "$RELEASE_DIR")" - cp "$RELEASE_DIR/checksums.sha256" /tmp/checksums.sha256 - (cd /tmp && sha256sum "$(basename "$PACKAGE")" > package.sha256) - -Tag the exact tested commit, push the tag, and create an explicitly unsigned -prerelease: - - git tag -a "$TEST_TAG" "$RELEASE_COMMIT" \ - -m "Unsigned mpc-ceremony rehearsal $TEST_TAG" - git push origin "refs/tags/$TEST_TAG" - gh release create "$TEST_TAG" \ - --repo zksecurity/proof-tool \ - --verify-tag \ - --prerelease \ - --title "UNSIGNED rehearsal: $TEST_TAG" \ - --notes "Unsigned test release for installation and compatibility testing. NOT FOR PRODUCTION CEREMONIES." \ - "$RELEASE_DIR/mpc-ceremony#mpc-ceremony (Linux amd64, unsigned rehearsal)" \ - "$PACKAGE#Complete unsigned verification package" \ - "/tmp/checksums.sha256#Binary checksums from the package" \ - "/tmp/package.sha256#Verification-package checksum" - -Download the assets into a fresh directory and compare them with the retained -local outputs before announcing the test: - - DOWNLOAD_DIR=$(mktemp -d /tmp/mpc-release-download.XXXXXXXX) - gh release download "$TEST_TAG" \ - --repo zksecurity/proof-tool \ - --dir "$DOWNLOAD_DIR" - sha256sum "$DOWNLOAD_DIR"/* - cmp "$DOWNLOAD_DIR/mpc-ceremony" "$RELEASE_DIR/mpc-ceremony" - -## Publish a production release - -Production is different in three ways: the source tag is signed by the approved -tag signer, the package manifest is signed by the offline release build key, -and an independent auditor reproduces and verifies the package before anything -is published. Never place the build-signing private key in GitHub Actions. - -On the offline Linux/amd64 release machine with Go 1.26.5, check out the approved -signed tag, bootstrap the vendor tree, and create two production builds: - - RELEASE_TAG=REPLACE_WITH_APPROVED_SIGNED_TAG - TAG_SIGNER_FINGERPRINT=REPLACE_WITH_APPROVED_FINGERPRINT - BUILD_SIGNING_KEY=/offline/mpc-build-signing-key - git fetch origin --tags - git checkout --detach "$RELEASE_TAG" - RELEASE_COMMIT=$(git rev-parse "$RELEASE_TAG^{commit}") - test "$(git rev-parse HEAD)" = "$RELEASE_COMMIT" - test -z "$(git status --porcelain)" - bash scripts/bootstrap-vendor.sh - mkdir -p /retained/mpc-release-a-parent /retained/mpc-release-b-parent - scripts/build-mpc-ceremony-release.sh \ - --mode production \ - --signed-tag "$RELEASE_TAG" \ - --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ - --build-signing-key "$BUILD_SIGNING_KEY" \ - --out-dir /retained/mpc-release-a-parent/release - scripts/build-mpc-ceremony-release.sh \ - --mode production \ - --signed-tag "$RELEASE_TAG" \ - --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ - --build-signing-key "$BUILD_SIGNING_KEY" \ - --out-dir /retained/mpc-release-b-parent/release - -The independent auditor obtains the build public key through the independent -trust channel and runs: - - TRUSTED_BUILD_PUBLIC_KEY=/trusted/mpc-build-public-key.hex - scripts/verify-mpc-ceremony-reproducible.sh \ - --mode production \ - --expected-commit "$RELEASE_COMMIT" \ - --expected-tag "$RELEASE_TAG" \ - --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ - --trusted-build-public-key-file "$TRUSTED_BUILD_PUBLIC_KEY" \ - /retained/mpc-release-a-parent/release \ - /retained/mpc-release-b-parent/release - -Package the verified `release` directory with the deterministic `tar` command -from the test procedure, replacing `TEST_TAG` with `RELEASE_TAG`. Upload the -direct binary, full package, and separate checksums with `gh release create`, -but omit `--prerelease` and all rehearsal wording. Publish the authenticated -release announcement only after an independent download-and-verify pass. - -Never reuse a test tag or replace assets on an existing release. If anything is -wrong, leave an audit trail, mark the release unusable, and publish a new tag. diff --git a/docs/mpc-ceremony-security-defenses.md b/docs/mpc-ceremony-security-defenses.md deleted file mode 100644 index 783ce45..0000000 --- a/docs/mpc-ceremony-security-defenses.md +++ /dev/null @@ -1,169 +0,0 @@ -# MPC Ceremony — Attack/Defense Inventory - -The deliberate security defenses in `internal/mpcceremony` and its CLI, each -mapped to the attack it counters, with code anchors (line numbers drift; treat -them as anchors, not guarantees). Known gaps at the end. Consumer-package -hardening (prover, wasm, streampk, proofassets) is tracked separately in the -"untrusted decode" PR. - -## The five ideas (ELI5) - -The ceremony is a group taking turns stirring secret ingredients into a shared -pot; the result is safe if one ingredient stays secret and nobody swaps the pot -unwatched. Almost every defense below is one of five ideas: - -1. **Never trust a label — check the contents.** Everything carries a hash, - recomputed at every use, not once. -2. **Never trust a path.** Look before opening, open, look again — symlinks and - mid-read swaps are caught. -3. **Write once, never overwrite.** History is append-only and hash-chained; - publishing is create-only-if-absent. -4. **One person can't cheat alone.** Distinct keys per role, multiple - sign-offs, randomness from a public beacon fixed in the future. -5. **Assume every input is hostile.** One canonical form, exact lengths, sane - bounds; two encodings of "the same" thing is an attack. - -## 1 · Filesystem - -- Symlink swap: `Lstat` + `ModeSymlink` rejection before every read - (`files.go` `openRegularExact`, `workflow.go` `readRegularBounded`, - publication/audit/decision walks); per-component parent check - (`rejectSymlinkComponents`). -- TOCTOU: `os.SameFile` after open, size stability during hash, trailing-byte - read after (`workflow.go`, `publication.go`, `keybundle`). -- Path traversal: clean-relative-name validation (`validateArtifactName`), - `filepath.Rel` containment (`resolveArtifactPath`), stdin/URL rejection at - the CLI. -- Overwrite/rollback: `O_EXCL`, hard-link publish, `RENAME_NOREPLACE`, - retry only against byte-identical existing state (`requireAbsentOrExact`); - signature published before its record; fsync with re-validating recovery. -- Permissions: 0600 files, 0700 dirs, group/world bits rejected; directory - member allowlists. -- Exhaustion: size caps everywhere (16 GiB artifacts, 16 MiB records, 1 MiB - drand, 4 KiB keys, 100k-entry trees). - -## 2 · Cryptographic - -- Forged records: Ed25519 over exact bytes before parsing; out-of-band - coordinator anchor; `KeyID` untrusted until the bytes authenticate - (`attestation.go` `VerifyExact`, `workflow.go` `LoadSignedDefinition`). -- Unusable identity keys: canonical-encoding and small-order rejection via - `filippo.io/edwards25519` (`validateEd25519PublicKey`) — a small-order key - verifies signatures for any message. -- Key substitution: fingerprint re-derived on load; private key must match the - enrolled identity. -- Artifact substitution: dual SHA-256+BLAKE2b+size pinning, re-hashed at every - use; R1CS digested before native decode; running binary digest-matched to - the signed definition on every command. -- Encoding equivalence: decoded gnark objects re-serialized and required - byte-identical (`requireCanonicalRoundTrip`). -- Invalid points: BLS12-381 compressed-flag check (`preflight.go`); gnark - subgroup checks on by default on the ceremony path. -- Context confusion: per-record-type domain tags + `0x00` separator; beacon - challenge uses length-prefixed tuple encoding; content-addressed record IDs - recomputed everywhere. -- Rigged beacon: drand quicknet chain/key/scheme pinned in the signed - definition; randomness derived from the verified BLS signature, never - operator-supplied. -- Broken verifier: finalization requires the verifier to *reject* seven - tampered variants (negative controls, `finalize.go`). -- Mutation aliasing: archived inputs cloned before gnark's mutating - `Verify`/`Seal` (`streamClone`; acceptance path verifies a throwaway clone); - spent seal heads not retained; panic boundaries around gnark decode/verify. - -## 3 · Serialization - -- Canonical JSON: duplicate/unknown-field and trailing-data rejection, then - re-marshal byte-equality (`UnmarshalCanonical`); depth/key caps - (`strictjson`). -- Length-field lies: exact-size `LimitedReader`, EOF proof, `math/bits` - overflow-checked arithmetic, allocation only after locally derived expected - sizes (`preflight.go` — Phase 2 shape never taken from an untrusted - artifact). -- Aliasing: lowercase exact-length hex; canonical RFC3339Nano timestamps. - -## 4 · Identity and roster - -- Sybil/role overlap: three-dimension uniqueness (ID, key ID, fingerprint) - across coordinator, release signer, auditors, roster, witnesses, mirrors; - release signer ≠ coordinator; external auditors disjoint from all actors. -- Deceptive names: control characters, bidi formatting, and zero-width - characters rejected in display names and artifact-name segments - (`rejectDeceptiveRunes` — explicit Cf list so ZWNJ/ZWJ stay writable); - 256-byte display-name cap; whitespace-only attested fields rejected. -- Bounds aligned across layers: auditors 2..20 at enrollment = transcript - capacity; IDs restricted to `[a-z0-9-_.:]`, 1..128. - -## 5 · Transcript and chain - -- History rewrite: hash-chained records (index, previous payload, previous - record ID), whole-chain validation on append, frozen scheduled participant - order, ≤20 records. -- Fake contributions: full replay from deterministic genesis with per-step - `Verify`; no-op contributions rejected; gnark challenge must equal SHA-256 - of the previous payload (binds native transcript to the JSON chain); - 10-field attestation binding plus chronology; erasure binds the contribution - and must postdate it. - -## 6 · Network - -- The package imports no networking; evidence URIs are validated - (`https`/`ipfs`, no userinfo/fragment) and recorded, never fetched. - -## 7 · Process and operations - -- Beacon precommitment: future-round requirement; round schedule pinned; lead - re-checked immediately before atomic publish; production reserves a witness - observation window on top of the signed minimum (`requiredCloseLead`) so - witness receipts stay satisfiable; derived rounds sampled from the - post-replay clock; Phase 2 round must differ from Phase 1. -- Quorums: witnesses ≥2 (distinct IDs and fingerprints, unanimous on closure), - 3–16 distinct-operator relay observations, 2–8 mirror receipts per head, - ≥2 audits. -- Production mode: clean git tree, pinned build profile, no module `replace`, - all scheduled participants required, running software re-verified per - command. -- Separation of duties: release needs ≥2 distinct passing audits and a - distinct pre-existing release key; GO needs coordinator + every named - auditor + release signer, exactly; audits bundled in auditor-ID order so - the transcript always matches the decision's required order. -- Recovery: read-only `inspect` reports chain state and the next scheduled - contribution from signed data only — no key, no writes, no replay. -- Release trees: exact name-set equality, no unpinned files, sorted checksum - manifests, ceilings derived from the bundle layers' own maxima (32768). - -## 8 · Other - -- CLI diagnostics redact argv by construction (single stderr outlet); short - values replaced only as whole tokens so short key IDs stay protected without - blanking unrelated digits. -- Secrets excluded from published evidence; fixed sidecar paths, no `latest` - discovery; golden public vector pinned. - -## Fixed during this audit - -Ed25519 point validation · deceptive-rune and display-name hardening · -whitespace-only attested fields · artifact-name control characters · -clone-before-verify on the acceptance path · witness observation window · -counted-gate alignment (auditor cap, audit ordering, release-tree ceiling) · -audits-gate label renamed while no signed record existed · redaction by -construction with token matching · read-only `inspect` · beacon round derived -post-replay · replay/seal/phase2-init progress reporting. - -## Known gaps (open) - -1. **`streampk` URL path has no digest verification.** `OpenKeyURL` range-reads - proving-key bytes into decoders with `NoSubgroupChecks()`; the compensating - `IsOnCurve` landed in the untrusted-decode PR, but nothing hashes the - fetched bytes against the signed manifest on that path. -2. **Mainnet has no script-hash recompile gate.** The exporter binds the VK - hash to the VK bytes, but nothing binds `reclaim_global.script_hash` to a - script recompiled from the VK outside the Preprod-pinned - `formal/scripts/lock-active-artifacts.mjs`. Fix belongs in - `ValidateReclaimDeployment` or by lifting the Preprod-only guard. -3. **Latent enrollment-cap overflow.** The bundle's per-category maxima - (witnesses, per-head mirror operators) sum past the 128-identity enrollment - cap; reachable only with genuinely distinct operators at every head. - Fails closed at bundle assembly. -4. **No constant-time comparisons in the package.** Defensible — every - comparison is over public values — recorded so reviewers don't re-derive it. From 76e442d8d36c1f98093453aed4da6079b2709311 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 10:30:49 +0000 Subject: [PATCH 33/42] test: allow full proof gate to complete --- scripts/test-all.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 294b546..0be0538 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -74,7 +74,7 @@ else # multi / destination round-trip integration tests (positive + tamper # cases). This is the strongest local evidence that proof generation works. run_step "go test (full, incl. real ownership Groth16 round-trips)" \ - env PROOF_TOOL_RUN_FULL_PROOF=1 go test ./... + env PROOF_TOOL_RUN_FULL_PROOF=1 go test -timeout 55m ./... fi run_step "wasm prover builds" env GOOS=js GOARCH=wasm go build -o /dev/null ./cmd/wasm-prover From 93d20cd6abe69ebe9b1582b0d41614a93b673cf3 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:23:12 +0900 Subject: [PATCH 34/42] docs(mpc-ceremony): finalize help reflects circuit-from-definition (#9) finalize prepare's help said it 'compiles this repository's destination-v2 R1CS', but executeFinalize/executeAudit resolve the circuit from the signed ceremony definition via compileCircuitForCeremony -> CompileForKeyVersion. A rehearsal-tiny-v1 ceremony is therefore finalized/audited against the rehearsal circuit, not destination-v2. The stale wording implies the tiny rehearsal cannot be finalized, which is incorrect. --- cmd/mpc-ceremony/usage.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 8684621..7af6d66 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -326,9 +326,10 @@ Records the distinct Phase 2 post-closure beacon evidence used by finalize. --out-dir FRESH_DIR ` + replayFlagsHelp + ` -Independently compiles this repository's destination-v2 R1CS, replays both -phases, and publishes a coordinator-signed preliminary native PK/VK tree. It -is not a candidate and cannot be audited or released. +Independently compiles the circuit named by the signed ceremony definition +(ownership-destination-v2 in production, rehearsal-tiny-v1 in a rehearsal), +replays both phases, and publishes a coordinator-signed preliminary native +PK/VK tree. It is not a candidate and cannot be audited or released. `, "finalize complete": `Usage: mpc-ceremony finalize complete --ceremony FILE --ceremony-signature FILE \ From 4fb8adf6e92f51fa6c109323bc927eeb3fabf0b5 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 13:32:22 +0000 Subject: [PATCH 35/42] ci(mpc): allow full Relay compatibility runtime --- .github/workflows/mpc-ceremony-release-validation.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml index 438db50..2f2bf11 100644 --- a/.github/workflows/mpc-ceremony-release-validation.yml +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -15,7 +15,7 @@ concurrency: env: # Update this only after reviewing the Relay change and rerunning this gate. - RELAY_COMMIT: 199dbce047af852896b0027457eb3da82b758fcd + RELAY_COMMIT: 1e73ebe903bcda6a1beabda87f323b819e372d34 jobs: rehearsal-reproducibility: @@ -122,4 +122,5 @@ jobs: go test ./cmd/relay \ -run '^TestProofToolCompatibility$' \ -count=1 \ + -timeout 30m \ -v From 7856824063d922a7e76d601c64fa2c938f47f8de Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 16:24:45 +0000 Subject: [PATCH 36/42] ci: decouple mpc release validation from Relay --- .../mpc-ceremony-release-validation.yml | 47 ------------------- docs/mpc-ceremony-release.md | 34 ++++++++++++++ docs/trusted-setup-ceremony.md | 6 +++ 3 files changed, 40 insertions(+), 47 deletions(-) create mode 100644 docs/mpc-ceremony-release.md diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml index 5795511..f4fe178 100644 --- a/.github/workflows/mpc-ceremony-release-validation.yml +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -13,10 +13,6 @@ concurrency: group: mpc-ceremony-release-${{ github.workflow }}-${{ github.ref }} cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} -env: - # Update this only after reviewing the Relay change and rerunning this gate. - RELAY_COMMIT: 0e631b319199254512ca753ed6f2d6c650fe3383 - jobs: rehearsal-reproducibility: name: Reproducible unsigned rehearsal @@ -120,46 +116,3 @@ jobs: test ! -e "$release/build-package-manifest.sig" test ! -e "$release/build-package-manifest-public-key.hex" done - - relay-compatibility: - name: Relay CLI compatibility (pinned commit) - needs: rehearsal-reproducibility - runs-on: ubuntu-latest - timeout-minutes: 35 - steps: - - name: Check out proof-tool - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - path: proof-tool - fetch-depth: 0 - persist-credentials: false - - - name: Check out pinned Relay - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - repository: zksecurity/relay - ref: ${{ env.RELAY_COMMIT }} - path: relay - persist-credentials: false - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: proof-tool/go.mod - cache: false - - - name: Bootstrap patched proof-tool vendor tree - working-directory: proof-tool - run: bash scripts/bootstrap-vendor.sh - - - name: Exercise the full proof-tool CLI boundary - shell: bash - run: | - test "$(git -C relay rev-parse HEAD)" = "$RELAY_COMMIT" - cd relay - RELAY_PROOF_TOOL_DIR="$GITHUB_WORKSPACE/proof-tool" \ - RELAY_PROOF_TOOL_FULL=1 \ - go test ./cmd/relay \ - -run '^TestProofToolCompatibility$' \ - -count=1 \ - -timeout 30m \ - -v diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md new file mode 100644 index 0000000..daea417 --- /dev/null +++ b/docs/mpc-ceremony-release.md @@ -0,0 +1,34 @@ +# Releasing `mpc-ceremony` + +`mpc-ceremony` is an independently released ceremony engine. Its release gate +must not check out, pin, or depend on a Relay source commit. This keeps the +ceremony parser, cryptographic implementation, and release decision owned by +proof-tool. + +The repository's `MPC ceremony release validation` workflow checks the +following proof-tool properties: + +- the approved Go toolchain and module identity; +- the patched vendor tree; +- two byte-for-byte reproducible unsigned rehearsal packages; +- the downloadable tiny rehearsal initializer and authenticated definition + projection; and +- absence of production signatures from rehearsal packages. + +Production release maintainers additionally follow +`scripts/build-mpc-ceremony-release.sh` and +`scripts/verify-mpc-ceremony-reproducible.sh` using the approved signed tag and +offline build-signing key. Publish the standalone `mpc-ceremony` binary and its +complete verification package through proof-tool's release process. + +## Coordinated distribution + +Compatibility with Relay is tested after both projects have released +independently. The ceremony-kit process receives the exact approved Relay and +`mpc-ceremony` repositories, tags, binaries, and SHA-256 hashes. It runs the +binary-only tiny-rehearsal compatibility gate and records the tested hashes in +the kit's `compatibility.json`. + +That downstream gate may reject a proposed pairing without invalidating either +independent release. Updating Relay never requires changing proof-tool's CI, +and releasing proof-tool never requires selecting a Relay commit. diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index debbe88..68c9381 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -10,6 +10,12 @@ This repository has two deliberately separate Groth16 setup paths: The commands, transcripts, and trust claims are not interchangeable. +The `mpc-ceremony` binary is also released independently of transport tools. +Its reproducibility and CLI checks do not fetch or pin a Relay commit. A +coordinated ceremony kit selects independently verified releases, tests the +exact binaries together, and records their hashes as described in +[`mpc-ceremony-release.md`](mpc-ceremony-release.md). + ## Single-Actor Local Setup Run the local path with: From 197e159b248abbf7375735055748bdb4b9660eb1 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 16:36:49 +0000 Subject: [PATCH 37/42] docs: replace stale MPC runbook links --- docs/README.md | 27 +++++++++------------------ docs/trusted-setup-ceremony.md | 19 +++++++++++-------- 2 files changed, 20 insertions(+), 26 deletions(-) diff --git a/docs/README.md b/docs/README.md index ee7a18f..18728e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -25,19 +25,15 @@ that foundation. - [`mpc-ceremony-parallel-optimizations.md`](mpc-ceremony-parallel-optimizations.md): gnark Phase 1/Phase 2 threading changes, safety invariants, benchmarks, and the initial exact K=21 comparison result. -- [`mpc-ceremony-runbook.md`](mpc-ceremony-runbook.md): production operator, - contributor, auditor, beacon, archival, replay, and release gates for the - dedicated two-phase BLS12-381 MPC ceremony. -- [`mpc-production-readiness.md`](mpc-production-readiness.md): the formal - mainnet go/no-go matrix, current **NO-GO**, blocking rehearsal incident, and - required evidence package for that ceremony. -- [`mpc-security-review.md`](mpc-security-review.md): pinned dependency - advisory dispositions, reviewed defenses, and independent review gates. -- [`mpc-external-audit-package.md`](mpc-external-audit-package.md): frozen - review scope, required independent tests, and auditor deliverables. -- [`mpc-production-go-no-go-template.md`](mpc-production-go-no-go-template.md): - exact mainnet ceremony, external, coherence, and accountable-signature - acceptance record. +- [`mpc-ceremony-release.md`](mpc-ceremony-release.md): independent + `mpc-ceremony` release gates and the downstream binary-pair compatibility + boundary. +- Relay's + [coordinator runbook](https://github.com/zksecurity/relay/blob/main/COORDINATOR_RUNBOOK.md) + and [role runbook](https://github.com/zksecurity/relay/blob/main/ROLE_RUNBOOK.md): + participant, witness, mirror, auditor, beacon, archival, and release + operations. The bundled Relay rehearsal is test-only; a production ceremony + requires its own independently reviewed go/no-go record. - [`proof-assets-release-inventory.md`](proof-assets-release-inventory.md): the current release identity and coherence values. @@ -77,11 +73,6 @@ that foundation. These remain plans because their external acceptance gates are still open: -- [`production-readiness.md`](production-readiness.md): current Mainnet - readiness verdict, evidence boundary, scorecard, and release gates. -- [`next-steps-to-mainnet.md`](next-steps-to-mainnet.md): status ledger for the - original readiness task IDs, distinguishing tracked, working-tree, external, - and open work. - [`circuit-proving-optimization-candidates.md`](circuit-proving-optimization-candidates.md): refreshed circuit/runtime optimization survey and current baselines. - [`manual-lace-claim-flow-qa-plan.md`](manual-lace-claim-flow-qa-plan.md): diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index 68c9381..1874c7b 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -4,9 +4,10 @@ This repository has two deliberately separate Groth16 setup paths: - `proof-tool setup-ceremony` is a reproducible, signed, single-actor local setup. -- `cmd/mpc-ceremony` is the two-phase multi-party workflow whose production - process is documented in - [`mpc-ceremony-runbook.md`](mpc-ceremony-runbook.md). +- `cmd/mpc-ceremony` is the two-phase multi-party engine. Relay's + [coordinator runbook](https://github.com/zksecurity/relay/blob/main/COORDINATOR_RUNBOOK.md) + and [role runbook](https://github.com/zksecurity/relay/blob/main/ROLE_RUNBOOK.md) + document the distributed transport and operator workflow. The commands, transcripts, and trust claims are not interchangeable. @@ -66,11 +67,13 @@ ordered contributions in both phases, uses separate future public beacons for Phase 1 and Phase 2, and supports full independent transcript replay. Software verification alone is still insufficient: participant independence, host controls, entropy quality, erasure, public archival, and independent audits are -operational requirements. See the full -[`MPC ceremony operator, contributor, and auditor runbook`](mpc-ceremony-runbook.md). -Its current Mainnet decision is **NO-GO**; see -[`mpc-production-readiness.md`](mpc-production-readiness.md) before using any -ceremony binary or artifact. +operational requirements. See Relay's +[coordinator runbook](https://github.com/zksecurity/relay/blob/main/COORDINATOR_RUNBOOK.md) +and [role runbook](https://github.com/zksecurity/relay/blob/main/ROLE_RUNBOOK.md) +for the deployed workflow. Relay's bundled rehearsal is test-only and does not +constitute production approval; each production ceremony requires an explicit, +independently reviewed go/no-go record before any ceremony binary or artifact +is used. ## Toxic Waste Handling From 16a609c7f90c5ce16f5a14ddfdc128c474ccce97 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 16:55:05 +0000 Subject: [PATCH 38/42] docs: describe full binary compatibility gate --- docs/mpc-ceremony-release.md | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index daea417..8abe771 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -26,8 +26,9 @@ complete verification package through proof-tool's release process. Compatibility with Relay is tested after both projects have released independently. The ceremony-kit process receives the exact approved Relay and `mpc-ceremony` repositories, tags, binaries, and SHA-256 hashes. It runs the -binary-only tiny-rehearsal compatibility gate and records the tested hashes in -the kit's `compatibility.json`. +binary-only tiny-rehearsal compatibility gate, including a real phase 1 +contribution, erasure attestation, coordinator acceptance, and accepted-chain +inspection, and records the tested hashes in the kit's `compatibility.json`. That downstream gate may reject a proposed pairing without invalidating either independent release. Updating Relay never requires changing proof-tool's CI, From 619660c378462b2926209993e2161379db6f030a Mon Sep 17 00:00:00 2001 From: mellowcroc Date: Fri, 21 Aug 2026 01:48:06 +0900 Subject: [PATCH 39/42] fix(mpc): pin the canonical production circuit at init A build made without the patched vendor tree resolves upstream gnark from the module cache and compiles a slightly different destination-v2 circuit (observed: 1,791,413 constraints instead of the canonical 1,789,750), because reviewed vendor patches such as the uints constant folding change the constraint system. Nothing fails on its own: init signs the wrong circuit into the ceremony definition and every later stage coherently verifies against it, so the fork is only discovered when the transcript is compared against the canonical circuit hours later, if at all. Pin the reviewed R1CS identity (sha256, blake2b256, size, constraint count) and reject it at production init with an error that names scripts/bootstrap-vendor.sh. Rehearsal mode and the rehearsal circuit are deliberately not pinned. Also record the measured exact-K=21 contribution and verification timings from the 2026-08-20 Relay-driven production-mode first-head test in the parallel-optimizations note. --- cmd/mpc-ceremony/executor.go | 8 +++ docs/mpc-ceremony-parallel-optimizations.md | 12 +++++ internal/mpcceremony/canonical.go | 57 ++++++++++++++++++++ internal/mpcceremony/canonical_test.go | 59 +++++++++++++++++++++ 4 files changed, 136 insertions(+) create mode 100644 internal/mpcceremony/canonical.go create mode 100644 internal/mpcceremony/canonical_test.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index e4843b1..b344b57 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -126,6 +126,14 @@ func executeInit(options InitOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } + if options.Mode == mpcceremony.ModeProduction { + // A build made without the patched vendor tree compiles a slightly + // different circuit that would otherwise become the signed truth of + // the ceremony. Reject the fork before anything is signed. + if err := mpcceremony.ValidateCanonicalDestinationV2(circuit.Binding); err != nil { + return CommandResult{}, err + } + } result, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{ RootDir: options.OutDir, Circuit: circuit, diff --git a/docs/mpc-ceremony-parallel-optimizations.md b/docs/mpc-ceremony-parallel-optimizations.md index 10fc976..a91a917 100644 --- a/docs/mpc-ceremony-parallel-optimizations.md +++ b/docs/mpc-ceremony-parallel-optimizations.md @@ -34,11 +34,23 @@ The first result from the exact K=21 comparison rehearsal is: | Exact K=21 stage | Previous run | Optimized run | Speedup | |---|---:|---:|---:| | Ceremony initialization | 12m16s | 1m34.76s | 7.8× | +| Phase 1 contribution (16 vCPU) | 56m54s | 7m02s | 8.1× | +| Phase 1 contribution (8 vCPU) | 56m54s | 8m01s | 7.1× | +| Candidate verification (accept) | 50m27s | 5m46s | 8.7× | The optimized initialization averaged 954% CPU according to GNU `time`, which means it used about 9.54 CPU cores concurrently. It reached a peak resident set size of approximately 3.38 GiB. +The contribution and verification rows were measured on 2026-08-20 during a +Relay-driven production-mode first-head test at the canonical circuit +(1,789,750 constraints): the 16-vCPU contribution ran on the same EPYC host +class as the serial baselines, the 8-vCPU contribution ran on a separate role +machine through `relay participate` (66m37s of CPU in 8m01s of wall clock), +and verification ran through the coordinator accept path (67m00s of CPU in +5m46s). The serial baselines are the corresponding stages of the completed +single-host K=21 production-mode run measured before these patches. + ## 1. Parallel Phase 1 Point Updates Each Phase 1 contribution updates millions of SRS points using fresh secret diff --git a/internal/mpcceremony/canonical.go b/internal/mpcceremony/canonical.go new file mode 100644 index 0000000..c066837 --- /dev/null +++ b/internal/mpcceremony/canonical.go @@ -0,0 +1,57 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import "fmt" + +// The reviewed identity of the production destination-v2 circuit, as compiled +// from the patched vendor tree that scripts/bootstrap-vendor.sh reconstructs. +// +// A build made without that vendor tree resolves upstream gnark from the +// module cache and compiles a slightly different circuit (observed: +// 1,791,413 constraints instead of 1,789,750), because reviewed patches such +// as the uints constant folding change the constraint system. Nothing about +// such a build fails on its own: init would sign the wrong circuit into the +// ceremony definition and every later stage would coherently verify against +// it. These constants let production init reject that fork at the source +// instead of discovering it after hours of ceremony compute. +// +// Update these values only when the reviewed circuit intentionally changes, +// together with the vendor patches and the release review that approves the +// new identity. +const ( + CanonicalDestinationV2SHA256 = "sha256:b5e629f47321048a6e2f85b3a839c1cf898454b69eef582f54e07d6d647074dc" + CanonicalDestinationV2Blake2b256 = "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352" + CanonicalDestinationV2Size = int64(129221468) + CanonicalDestinationV2Constraints = uint64(1789750) +) + +// ValidateCanonicalDestinationV2 rejects a compiled destination-v2 circuit +// whose serialized identity differs from the reviewed canonical build. It says +// nothing about other key versions: the rehearsal circuit is deliberately not +// pinned here. +func ValidateCanonicalDestinationV2(binding CircuitBinding) error { + if binding.KeyVersion != KeyVersionDestinationV2 { + return nil + } + if binding.Constraints != CanonicalDestinationV2Constraints { + return fmt.Errorf( + "compiled destination-v2 circuit has %d constraints, want canonical %d; "+ + "rebuild from the patched vendor tree (scripts/bootstrap-vendor.sh, then go build -mod=vendor)", + binding.Constraints, + CanonicalDestinationV2Constraints, + ) + } + if binding.R1CS.Digest.SHA256 != CanonicalDestinationV2SHA256 || + binding.R1CS.Digest.Blake2b256 != CanonicalDestinationV2Blake2b256 || + binding.R1CS.Digest.Size != CanonicalDestinationV2Size { + return fmt.Errorf( + "compiled destination-v2 R1CS digest %s (%d bytes) does not match the canonical reviewed build; "+ + "rebuild from the patched vendor tree (scripts/bootstrap-vendor.sh, then go build -mod=vendor)", + binding.R1CS.Digest.SHA256, + binding.R1CS.Digest.Size, + ) + } + return nil +} diff --git a/internal/mpcceremony/canonical_test.go b/internal/mpcceremony/canonical_test.go new file mode 100644 index 0000000..f53a717 --- /dev/null +++ b/internal/mpcceremony/canonical_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "strings" + "testing" +) + +func canonicalBinding() CircuitBinding { + return CircuitBinding{ + KeyVersion: KeyVersionDestinationV2, + R1CS: ArtifactRef{ + Name: "ownership-destination.ccs", + Digest: Digest{ + SHA256: CanonicalDestinationV2SHA256, + Blake2b256: CanonicalDestinationV2Blake2b256, + Size: CanonicalDestinationV2Size, + }, + }, + Constraints: CanonicalDestinationV2Constraints, + } +} + +func TestValidateCanonicalDestinationV2(t *testing.T) { + if err := ValidateCanonicalDestinationV2(canonicalBinding()); err != nil { + t.Fatalf("canonical binding rejected: %v", err) + } + + // The rehearsal circuit is not pinned by this check. + rehearsal := canonicalBinding() + rehearsal.KeyVersion = "rehearsal-tiny-v1" + rehearsal.Constraints = 5 + if err := ValidateCanonicalDestinationV2(rehearsal); err != nil { + t.Fatalf("non-destination-v2 binding rejected: %v", err) + } + + // The observed unvendored-build fork: same key version, different circuit. + unvendored := canonicalBinding() + unvendored.Constraints = 1791413 + err := ValidateCanonicalDestinationV2(unvendored) + if err == nil || !strings.Contains(err.Error(), "bootstrap-vendor.sh") { + t.Fatalf("unvendored constraint count accepted or unhelpful error: %v", err) + } + + mutations := []func(*CircuitBinding){ + func(b *CircuitBinding) { b.R1CS.Digest.SHA256 = "sha256:" + strings.Repeat("0", 64) }, + func(b *CircuitBinding) { b.R1CS.Digest.Blake2b256 = "blake2b256:" + strings.Repeat("0", 64) }, + func(b *CircuitBinding) { b.R1CS.Digest.Size = CanonicalDestinationV2Size + 1 }, + } + for i, mutate := range mutations { + binding := canonicalBinding() + mutate(&binding) + if err := ValidateCanonicalDestinationV2(binding); err == nil { + t.Fatalf("mutation %d accepted", i) + } + } +} From a81f8003b95d91db7e179b324b119ef1b3d3fb97 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 18:25:48 +0000 Subject: [PATCH 40/42] fix: harden production release validation --- .../mpc-ceremony-release-validation.yml | 2 +- .github/workflows/release-proof-helper.yml | 64 +++++++++---------- .../scripts/stage-windows-release.mjs | 5 +- .../scripts/stage-windows-release.test.mjs | 61 ++++++++++++++++++ cmd/mpc-ceremony/decision_test.go | 19 ++++-- cmd/mpc-ceremony/executor.go | 6 +- docs/proof-helper-windows-release-runbook.md | 10 ++- go.mod | 2 +- internal/mpcceremony/adversarial_test.go | 23 ++++--- internal/mpcceremony/canonical.go | 4 +- internal/mpcceremony/definition.go | 5 ++ internal/mpcceremony/definition_test.go | 36 ++++++++++- internal/mpcceremony/model.go | 2 +- .../mpcceremony/operational_bundle_test.go | 5 ++ internal/mpcceremony/software_test.go | 2 +- internal/mpcceremony/workflow_test.go | 52 +++++++++++++++ scripts/build-mpc-ceremony-release.sh | 4 +- scripts/verify-mpc-build-metadata/main.go | 4 +- scripts/verify-mpc-ceremony-reproducible.sh | 4 +- 19 files changed, 245 insertions(+), 65 deletions(-) create mode 100644 apps/proof-helper-desktop/scripts/stage-windows-release.test.mjs diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml index f4fe178..b2d5e9c 100644 --- a/.github/workflows/mpc-ceremony-release-validation.yml +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -32,7 +32,7 @@ jobs: - name: Verify release inputs shell: bash run: | - test "$(go env GOVERSION)" = go1.26.5 + test "$(go env GOVERSION)" = go1.26.6 test "$(go env GOHOSTOS)" = linux test "$(go env GOHOSTARCH)" = amd64 test "$(sed -n 's/^module //p' go.mod)" = proof-tool diff --git a/.github/workflows/release-proof-helper.yml b/.github/workflows/release-proof-helper.yml index 75878ca..37b8e35 100644 --- a/.github/workflows/release-proof-helper.yml +++ b/.github/workflows/release-proof-helper.yml @@ -12,18 +12,13 @@ on: required: true default: false type: boolean - signed_release: - description: Mark release notes as signed. Use only after Authenticode signatures are verified. - required: true - default: false - type: boolean - permissions: - contents: write + contents: read env: PROOF_HELPER_APP_DIR: apps/proof-helper-desktop PROOF_HELPER_TAURI_DIR: apps/proof-helper-desktop/src-tauri + PROOF_HELPER_RELEASE_TAG: ${{ inputs.tag }} WINDOWS_TARGET: x86_64-pc-windows-msvc WINDOWS_SIDECAR: proof-tool-x86_64-pc-windows-msvc.exe # Production origin the desktop helper pairs and opens by default. @@ -37,25 +32,32 @@ jobs: name: Release checks runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Refuse reserved portable-helper tag shell: bash run: | - if [[ "${{ inputs.tag }}" == "proof-helper-v0.1.0" ]]; then + if [[ "$PROOF_HELPER_RELEASE_TAG" == "proof-helper-v0.1.0" ]]; then echo "::error::proof-helper-v0.1.0 is reserved for portable fixture-helper bundles. Use a new desktop tag." exit 1 fi + if [[ ! "$PROOF_HELPER_RELEASE_TAG" =~ ^proof-helper-desktop-v[0-9A-Za-z][0-9A-Za-z._-]*$ ]] || + ! git check-ref-format "refs/tags/$PROOF_HELPER_RELEASE_TAG"; then + echo "::error::tag must be a valid proof-helper-desktop-v... Git tag" + exit 1 + fi - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 with: version: 10.18.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm @@ -64,7 +66,7 @@ jobs: apps/ownership-proof-web/pnpm-lock.yaml apps/proof-helper-desktop/pnpm-lock.yaml - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - name: Linux Tauri system dependencies run: | @@ -123,23 +125,25 @@ jobs: needs: checks runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 with: version: 10.18.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm cache-dependency-path: apps/proof-helper-desktop/pnpm-lock.yaml - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: x86_64-pc-windows-msvc @@ -173,11 +177,9 @@ jobs: - name: Stage Windows release artifacts working-directory: apps/proof-helper-desktop shell: bash - env: - PROOF_HELPER_WINDOWS_SIGNED: ${{ inputs.signed_release && '1' || '0' }} run: | pnpm release:stage-windows -- \ - --tag "${{ inputs.tag }}" \ + --tag "$PROOF_HELPER_RELEASE_TAG" \ --bundle-dir "src-tauri/target/x86_64-pc-windows-msvc/release/bundle" \ --sidecar "src-tauri/binaries/proof-tool-x86_64-pc-windows-msvc.exe" \ --out-dir "../../dist/proof-helper-windows-x64" @@ -186,22 +188,18 @@ jobs: shell: bash run: | mkdir -p dist/proof-helper-windows-x64 - signing_status="Unsigned preview" - if [[ "${{ inputs.signed_release }}" == "true" ]]; then - signing_status="Signed release" - fi { - echo "Proof Helper Windows ${{ inputs.tag }}" + echo "Proof Helper Windows $PROOF_HELPER_RELEASE_TAG" echo echo "- Target: x86_64-pc-windows-msvc" - echo "- Signing status: ${signing_status}" + echo "- Signing status: Unsigned preview" echo "- Proof-assets descriptor: proof-assets-ownership-destination-v2-preprod-9fac96b-g3a" echo "- Proof-assets download route: public HTTPS (GitHub release asset), size and hashes pinned in the app descriptor" echo echo "Do not describe this as a general Windows end-user release until Authenticode signatures, a public proof-assets download route, and local Windows release-build validation are complete." } > dist/proof-helper-windows-x64/release-notes.md - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: proof-helper-windows-x64 path: dist/proof-helper-windows-x64/* @@ -210,17 +208,19 @@ jobs: name: Draft GitHub release needs: build-windows runs-on: ubuntu-22.04 + permissions: + contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: proof-helper-windows-x64 path: release-artifacts - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - tag_name: ${{ inputs.tag }} - name: Proof Helper Windows ${{ inputs.tag }} + tag_name: ${{ env.PROOF_HELPER_RELEASE_TAG }} + name: Proof Helper Windows ${{ env.PROOF_HELPER_RELEASE_TAG }} draft: ${{ inputs.publish_release != true }} prerelease: true body_path: release-artifacts/release-notes.md diff --git a/apps/proof-helper-desktop/scripts/stage-windows-release.mjs b/apps/proof-helper-desktop/scripts/stage-windows-release.mjs index 8ea4d33..7754d2a 100644 --- a/apps/proof-helper-desktop/scripts/stage-windows-release.mjs +++ b/apps/proof-helper-desktop/scripts/stage-windows-release.mjs @@ -100,7 +100,10 @@ const manifest = { package_version: packageJson.version, tauri_config_version: tauriConfig.version, cargo_version: cargoVersion, - signed: process.env.PROOF_HELPER_WINDOWS_SIGNED === "1", + // Staging does not Authenticode-sign or verify artifacts. A future signed + // release path must derive this field from signature verification, never + // from operator input. + signed: false, sidecar: { name: path.basename(sidecarPath), target: TARGET, diff --git a/apps/proof-helper-desktop/scripts/stage-windows-release.test.mjs b/apps/proof-helper-desktop/scripts/stage-windows-release.test.mjs new file mode 100644 index 0000000..8900fbf --- /dev/null +++ b/apps/proof-helper-desktop/scripts/stage-windows-release.test.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("stages Windows artifacts as unsigned even when the environment claims signing", async () => { + const root = await fsp.mkdtemp("/tmp/proof-helper-windows-stage-"); + tempDirs.push(root); + const app = path.join(root, "apps", "proof-helper-desktop"); + const bundle = path.join(root, "bundle"); + await fsp.mkdir(path.join(app, "src-tauri"), { recursive: true }); + await fsp.mkdir(bundle, { recursive: true }); + await fsp.writeFile(path.join(app, "package.json"), '{"version":"0.2.2"}\n'); + await fsp.writeFile( + path.join(app, "src-tauri", "tauri.conf.json"), + '{"version":"0.2.2","productName":"Proof Helper"}\n', + ); + await fsp.writeFile( + path.join(app, "src-tauri", "Cargo.toml"), + '[package]\nname = "proof-helper-desktop"\nversion = "0.2.2"\n', + ); + + const installer = path.join(bundle, "Proof Helper.msi"); + const sidecar = path.join(root, "proof-tool-x86_64-pc-windows-msvc.exe"); + const out = path.join(root, "out"); + await fsp.writeFile(installer, "installer-bytes"); + await fsp.writeFile(sidecar, "sidecar-bytes"); + + execFileSync( + process.execPath, + [ + path.resolve("scripts/stage-windows-release.mjs"), + "--repo-root", + root, + "--tag", + "proof-helper-desktop-v0.2.2-windows-preview.1", + "--bundle-dir", + bundle, + "--sidecar", + sidecar, + "--out-dir", + out, + ], + { env: { ...process.env, PROOF_HELPER_WINDOWS_SIGNED: "1" } }, + ); + + const artifact = "proof-helper_0.2.2_windows_x64.msi"; + const bytes = await fsp.readFile(path.join(out, artifact)); + const digest = createHash("sha256").update(bytes).digest("hex"); + expect(await fsp.readFile(path.join(out, `${artifact}.sha256`), "utf8")).toBe(`${digest} ${artifact}\n`); + const manifest = JSON.parse(await fsp.readFile(path.join(out, "proof-helper-windows-release-manifest.json"), "utf8")); + expect(manifest.signed).toBe(false); +}); diff --git a/cmd/mpc-ceremony/decision_test.go b/cmd/mpc-ceremony/decision_test.go index 5494212..7735378 100644 --- a/cmd/mpc-ceremony/decision_test.go +++ b/cmd/mpc-ceremony/decision_test.go @@ -207,12 +207,19 @@ func decisionSignFixture(t *testing.T) (mpcceremony.CeremonyDefinition, []byte, CreatedAt: "2026-07-23T12:00:00Z", SessionNonceHex: strings.Repeat("5a", 32), Circuit: mpcceremony.CircuitBinding{ - KeyVersion: mpcceremony.KeyVersionDestinationV2, - CircuitID: mpcceremony.CircuitIDDestinationV2, - Curve: mpcceremony.CurveBLS12381, - Backend: mpcceremony.BackendGroth16, - R1CS: decisionArtifact("circuit.ccs", "r1cs").Artifact, - Constraints: 1_789_750, + KeyVersion: mpcceremony.KeyVersionDestinationV2, + CircuitID: mpcceremony.CircuitIDDestinationV2, + Curve: mpcceremony.CurveBLS12381, + Backend: mpcceremony.BackendGroth16, + R1CS: mpcceremony.ArtifactRef{ + Name: "circuit.ccs", + Digest: mpcceremony.Digest{ + SHA256: mpcceremony.CanonicalDestinationV2SHA256, + Blake2b256: mpcceremony.CanonicalDestinationV2Blake2b256, + Size: mpcceremony.CanonicalDestinationV2Size, + }, + }, + Constraints: mpcceremony.CanonicalDestinationV2Constraints, InternalVariables: 3, SecretVariables: 2, PublicVariables: 1, diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index b344b57..ce54b09 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -127,9 +127,9 @@ func executeInit(options InitOptions) (CommandResult, error) { return CommandResult{}, err } if options.Mode == mpcceremony.ModeProduction { - // A build made without the patched vendor tree compiles a slightly - // different circuit that would otherwise become the signed truth of - // the ceremony. Reject the fork before anything is signed. + // Fail before allocating and writing the large Phase 1 genesis. The same + // invariant is also enforced by CeremonyDefinition validation so no + // consumer can bypass it by creating or loading a definition elsewhere. if err := mpcceremony.ValidateCanonicalDestinationV2(circuit.Binding); err != nil { return CommandResult{}, err } diff --git a/docs/proof-helper-windows-release-runbook.md b/docs/proof-helper-windows-release-runbook.md index dfbbb9b..616bca2 100644 --- a/docs/proof-helper-windows-release-runbook.md +++ b/docs/proof-helper-windows-release-runbook.md @@ -108,8 +108,7 @@ Dispatch the workflow from a clean pushed commit: gh workflow run release-proof-helper.yml \ --ref main \ -f tag=proof-helper-desktop-v0.1.0-windows-preview.1 \ - -f publish_release=false \ - -f signed_release=false + -f publish_release=false ``` Watch the run: @@ -131,6 +130,11 @@ The workflow: `proof-helper-windows-release-manifest.json`. 6. Creates a draft prerelease unless `publish_release=true`. +This workflow can only produce an unsigned preview. It does not accept a flag +that marks artifacts as signed, because neither the workflow nor its staging +script performs or verifies Authenticode signing. A future signed release path +must set release metadata from successful signature verification. + ## Local Windows Build The canonical release path is still the GitHub Actions workflow above. Use this @@ -206,7 +210,7 @@ Check the manifest fields before testing: - `sidecar.sha256` is present. - `proof_assets_descriptor.download_configured` matches the intended release status. -- `signed` matches the Authenticode status that was actually verified. +- `signed` is `false`; this workflow only stages unsigned previews. ## Windows Validation diff --git a/go.mod b/go.mod index a341d7f..629e64c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module proof-tool -go 1.26.5 +go 1.26.6 require ( filippo.io/edwards25519 v1.2.0 diff --git a/internal/mpcceremony/adversarial_test.go b/internal/mpcceremony/adversarial_test.go index 72a2f71..b1c353a 100644 --- a/internal/mpcceremony/adversarial_test.go +++ b/internal/mpcceremony/adversarial_test.go @@ -206,16 +206,23 @@ func adversarialDefinition(t *testing.T) CeremonyDefinition { CreatedAt: "2026-07-23T12:00:00Z", SessionNonceHex: strings.Repeat("5a", 32), Circuit: CircuitBinding{ - KeyVersion: KeyVersionDestinationV2, - CircuitID: CircuitIDDestinationV2, - Curve: CurveBLS12381, - Backend: BackendGroth16, - R1CS: ArtifactRef{Name: "ownership-destination.ccs", Digest: NewDigest([]byte("r1cs"))}, - Constraints: 7, + KeyVersion: KeyVersionDestinationV2, + CircuitID: CircuitIDDestinationV2, + Curve: CurveBLS12381, + Backend: BackendGroth16, + R1CS: ArtifactRef{ + Name: "ownership-destination.ccs", + Digest: Digest{ + SHA256: CanonicalDestinationV2SHA256, + Blake2b256: CanonicalDestinationV2Blake2b256, + Size: CanonicalDestinationV2Size, + }, + }, + Constraints: CanonicalDestinationV2Constraints, InternalVariables: 3, SecretVariables: 2, PublicVariables: 1, - DomainSize: 8, + DomainSize: 1 << 21, Phase2Shape: Phase2Shape{ Commitments: 1, PKK: 1, @@ -419,7 +426,7 @@ func TestCeremonyDefinitionRejectsMetadataDrift(t *testing.T) { }}, {name: "dirty source", mutate: func(d *CeremonyDefinition) { d.Software.SourceDirty = true }}, {name: "wrong Go version", mutate: func(d *CeremonyDefinition) { - d.Software.GoVersion = "go1.26.6" + d.Software.GoVersion = "go1.26.5" }}, {name: "wrong target OS", mutate: func(d *CeremonyDefinition) { d.Software.GoOS = "darwin" diff --git a/internal/mpcceremony/canonical.go b/internal/mpcceremony/canonical.go index c066837..940b9d8 100644 --- a/internal/mpcceremony/canonical.go +++ b/internal/mpcceremony/canonical.go @@ -19,7 +19,9 @@ import "fmt" // // Update these values only when the reviewed circuit intentionally changes, // together with the vendor patches and the release review that approves the -// new identity. +// new identity. Production definition validation calls this function, so the +// invariant is enforced when definitions are created or consumed rather than +// only by the init command. const ( CanonicalDestinationV2SHA256 = "sha256:b5e629f47321048a6e2f85b3a839c1cf898454b69eef582f54e07d6d647074dc" CanonicalDestinationV2Blake2b256 = "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352" diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index abad5d0..22d1f46 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -178,6 +178,11 @@ func (d CeremonyDefinition) validate(requireID bool) error { if err := d.Circuit.Validate(); err != nil { return fmt.Errorf("circuit: %w", err) } + if d.Mode == ModeProduction { + if err := ValidateCanonicalDestinationV2(d.Circuit); err != nil { + return fmt.Errorf("circuit: %w", err) + } + } if err := d.Software.Validate(); err != nil { return fmt.Errorf("software: %w", err) } diff --git a/internal/mpcceremony/definition_test.go b/internal/mpcceremony/definition_test.go index 91618ad..24ee258 100644 --- a/internal/mpcceremony/definition_test.go +++ b/internal/mpcceremony/definition_test.go @@ -1,6 +1,40 @@ package mpcceremony -import "testing" +import ( + "strings" + "testing" +) + +func TestProductionDefinitionRequiresCanonicalDestinationCircuit(t *testing.T) { + tests := []struct { + name string + mutate func(*CircuitBinding) + }{ + { + name: "unvendored constraint count", + mutate: func(binding *CircuitBinding) { + binding.Constraints = 1791413 + }, + }, + { + name: "different R1CS digest", + mutate: func(binding *CircuitBinding) { + binding.R1CS.Digest.SHA256 = "sha256:" + strings.Repeat("0", 64) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + test.mutate(&definition.Circuit) + if _, err := FinalizeCeremonyDefinition(definition); err == nil { + t.Fatal("production definition with noncanonical destination circuit unexpectedly accepted") + } + }) + } +} func TestProductionDefinitionRequiresMultipleParticipantsInBothPhases(t *testing.T) { valid := adversarialDefinition(t) diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index aa15c5c..8f1d261 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -43,7 +43,7 @@ const ( GnarkVersion = "v0.15.0" GnarkCryptoVersion = "v0.20.1" DrandVersion = "v2.1.6" - ProductionGoVersion = "go1.26.5" + ProductionGoVersion = "go1.26.6" ProductionGOOS = "linux" ProductionGOARCH = "amd64" ProductionGOAMD64 = "v1" diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 8e95b89..6501450 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -397,6 +397,11 @@ func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { round42Time, _ := QuicknetRoundTime(42) definition.Mode = ModeRehearsal definition.CreatedAt = round42Time.Add(-30 * time.Hour).Format(time.RFC3339) + // Keep this downstream evidence fixture small. Canonical production circuit + // identity is covered by definition_test.go; rehearsal mode may bind these + // synthetic R1CS bytes so the exact release-tree checks can exercise real + // files without embedding the 129 MB production constraint system. + definition.Circuit.R1CS.Digest = NewDigest([]byte("r1cs")) definition.Circuit.Constraints = 1_789_750 definition.Circuit.DomainSize = 1 << 21 definition.Phase1Policy.Minimum = 1 diff --git a/internal/mpcceremony/software_test.go b/internal/mpcceremony/software_test.go index f5ba025..d6f81cd 100644 --- a/internal/mpcceremony/software_test.go +++ b/internal/mpcceremony/software_test.go @@ -267,7 +267,7 @@ func TestRunningSoftwareBindingRejectsUnverifiableBuilds(t *testing.T) { { name: "unapproved Go version", mutate: func(info *debug.BuildInfo) { - info.GoVersion = "go1.26.6" + info.GoVersion = "go1.26.5" }, }, { diff --git a/internal/mpcceremony/workflow_test.go b/internal/mpcceremony/workflow_test.go index 47def26..e7283e8 100644 --- a/internal/mpcceremony/workflow_test.go +++ b/internal/mpcceremony/workflow_test.go @@ -4,12 +4,64 @@ import ( "bytes" "crypto/ed25519" "encoding/hex" + "encoding/json" "os" "path/filepath" "strings" "testing" ) +func TestLoadSignedDefinitionRejectsAuthenticatedNoncanonicalProductionCircuit(t *testing.T) { + definition := adversarialDefinition(t) + definition.Circuit.Constraints = 1791413 + definition.CeremonyID = "" + // Simulate a coordinator that signs a self-consistent but unapproved + // definition. canonicalHash is used directly because the public constructor + // correctly refuses to create this definition. + ceremonyID, err := canonicalHash("proof-tool/mpc-ceremony/root/v1", definition) + if err != nil { + t.Fatal(err) + } + definition.CeremonyID = ceremonyID + definitionBytes, err := json.Marshal(definition) + if err != nil { + t.Fatal(err) + } + privateKey := adversarialPrivateKey(0x01) + signature, err := SignExact(definitionBytes, definition.Coordinator.KeyID, privateKey) + if err != nil { + t.Fatal(err) + } + signatureBytes, err := MarshalCanonical(signature) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + definitionPath := filepath.Join(dir, "ceremony.json") + signaturePath := filepath.Join(dir, "ceremony.sig") + publicKeyPath := filepath.Join(dir, "coordinator-public-key.hex") + if err := os.WriteFile(definitionPath, definitionBytes, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(signaturePath, signatureBytes, 0o600); err != nil { + t.Fatal(err) + } + publicKey := privateKey.Public().(ed25519.PublicKey) + if err := os.WriteFile(publicKeyPath, []byte(hex.EncodeToString(publicKey)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err = LoadSignedDefinition(TrustPaths{ + DefinitionPath: definitionPath, + DefinitionSignaturePath: signaturePath, + CoordinatorPublicKeyPath: publicKeyPath, + }) + if err == nil || !strings.Contains(err.Error(), "canonical") { + t.Fatalf("authenticated noncanonical production circuit error = %v", err) + } +} + func TestResolveArtifactPathRejectsSymlinkComponent(t *testing.T) { root := t.TempDir() outside := t.TempDir() diff --git a/scripts/build-mpc-ceremony-release.sh b/scripts/build-mpc-ceremony-release.sh index 24258cf..370b4eb 100755 --- a/scripts/build-mpc-ceremony-release.sh +++ b/scripts/build-mpc-ceremony-release.sh @@ -171,8 +171,8 @@ if [[ ! -x "$GO_BIN" || -L "$GO_BIN" ]]; then exit 1 fi GO_VERSION=$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOVERSION) -if [[ "$GO_VERSION" != "go1.26.5" ]]; then - echo "FAIL: release build requires go1.26.5, found $GO_VERSION" >&2 +if [[ "$GO_VERSION" != "go1.26.6" ]]; then + echo "FAIL: release build requires go1.26.6, found $GO_VERSION" >&2 exit 1 fi GO_HOST_OS=$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOHOSTOS) diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index 2cffa99..f28f562 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -32,7 +32,7 @@ import ( ) const ( - productionGoVersion = "go1.26.5" + productionGoVersion = "go1.26.6" expectedBuildFlags = "-mod=vendor\x00-trimpath\x00-buildvcs=true\x00-ldflags=-buildid=" ) @@ -256,7 +256,7 @@ func verifyToolchainChecksums(path string) error { return err } if string(data) != expected { - return errors.New("toolchain-checksums.sha256 does not identify the approved Go 1.26.5 linux/amd64 toolchain") + return errors.New("toolchain-checksums.sha256 does not identify the approved Go 1.26.6 linux/amd64 toolchain") } return nil } diff --git a/scripts/verify-mpc-ceremony-reproducible.sh b/scripts/verify-mpc-ceremony-reproducible.sh index 7dbf6d8..c3de2f7 100755 --- a/scripts/verify-mpc-ceremony-reproducible.sh +++ b/scripts/verify-mpc-ceremony-reproducible.sh @@ -160,8 +160,8 @@ ACTIVE_GOROOT=$(env -u GOROOT \ go env GOROOT) GO_BIN="$ACTIVE_GOROOT/bin/go" if [[ ! -x "$GO_BIN" || -L "$GO_BIN" || - "$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOVERSION)" != "go1.26.5" ]]; then - echo "FAIL: semantic verification requires the approved Go 1.26.5 toolchain" >&2 + "$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOVERSION)" != "go1.26.6" ]]; then + echo "FAIL: semantic verification requires the approved Go 1.26.6 toolchain" >&2 exit 1 fi From dc1cad16598546a0f2f0999da2fcde4ddd5e77f9 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 18:37:07 +0000 Subject: [PATCH 41/42] fix: pin Go 1.26.6 toolchain hashes --- scripts/build-mpc-ceremony-release.sh | 8 ++++---- scripts/verify-mpc-build-metadata/main.go | 8 ++++---- 2 files changed, 8 insertions(+), 8 deletions(-) diff --git a/scripts/build-mpc-ceremony-release.sh b/scripts/build-mpc-ceremony-release.sh index 370b4eb..cad4c1c 100755 --- a/scripts/build-mpc-ceremony-release.sh +++ b/scripts/build-mpc-ceremony-release.sh @@ -185,10 +185,10 @@ if [[ -n "$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOF echo "FAIL: release build requires an empty GOEXPERIMENT" >&2 exit 1 fi -EXPECTED_GO_SHA256=8da5fd321795754b994c64e3eb8a5a14ff47bd285559a7e876f3c79abafc67f9 -EXPECTED_COMPILE_SHA256=10c67b9de41c1e546b9bf416ceef410e5e3dd87a76d129b08b74a9570db9c463 -EXPECTED_LINK_SHA256=e58a36e6550a32ed7175cd6e2a1824dc66c034d1e3539ebeac8af719a9150d5d -EXPECTED_ASM_SHA256=0c9a07447aba3ed1df7a0a3e85f6e003d9bf312d2936dfc4b79e3d81e8ca7636 +EXPECTED_GO_SHA256=29e6e0b8be61beb1489ceae62b304343566de8a1dc700af74bde7aeb9c80ad45 +EXPECTED_COMPILE_SHA256=73da54c06c0702ae7c8cff309dd3958980af7ae0307cf319d7d0cb2bbd3fafd2 +EXPECTED_LINK_SHA256=048670775edfd89c6551149c197816dacdcc12c518b29ff1c533751b2dc4b976 +EXPECTED_ASM_SHA256=769ac2d73d09b7cc5479acdeb9f168c1772743420ffca2d9e990a9b0348d2836 GO_TOOL_DIR=$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOTOOLDIR) verify_tool_hash() { local path=$1 diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index f28f562..d253626 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -247,10 +247,10 @@ func verifyPlainIdentity(dir, mode, commit, tag, fingerprint string) error { func verifyToolchainChecksums(path string) error { const expected = "" + - "8da5fd321795754b994c64e3eb8a5a14ff47bd285559a7e876f3c79abafc67f9 go\n" + - "10c67b9de41c1e546b9bf416ceef410e5e3dd87a76d129b08b74a9570db9c463 compile\n" + - "e58a36e6550a32ed7175cd6e2a1824dc66c034d1e3539ebeac8af719a9150d5d link\n" + - "0c9a07447aba3ed1df7a0a3e85f6e003d9bf312d2936dfc4b79e3d81e8ca7636 asm\n" + "29e6e0b8be61beb1489ceae62b304343566de8a1dc700af74bde7aeb9c80ad45 go\n" + + "73da54c06c0702ae7c8cff309dd3958980af7ae0307cf319d7d0cb2bbd3fafd2 compile\n" + + "048670775edfd89c6551149c197816dacdcc12c518b29ff1c533751b2dc4b976 link\n" + + "769ac2d73d09b7cc5479acdeb9f168c1772743420ffca2d9e990a9b0348d2836 asm\n" data, err := os.ReadFile(path) if err != nil { return err From 04a9fa5a241fe614471c80da272b4089ef5ed8c6 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:43:02 +0900 Subject: [PATCH 42/42] feat: generate ceremony signing identities --- cmd/mpc-ceremony/cli_test.go | 11 ++ cmd/mpc-ceremony/executor.go | 2 + cmd/mpc-ceremony/identity.go | 132 ++++++++++++++++ cmd/mpc-ceremony/identity_test.go | 215 +++++++++++++++++++++++++++ cmd/mpc-ceremony/integration_test.go | 7 + cmd/mpc-ceremony/main.go | 12 ++ cmd/mpc-ceremony/parse.go | 43 ++++++ cmd/mpc-ceremony/types.go | 9 ++ cmd/mpc-ceremony/usage.go | 32 +++- 9 files changed, 459 insertions(+), 4 deletions(-) create mode 100644 cmd/mpc-ceremony/identity.go create mode 100644 cmd/mpc-ceremony/identity_test.go diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 3239e91..29174de 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -69,6 +69,17 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { args []string command Command }{ + { + name: "identity generate", + args: []string{ + "identity", "generate", + "--identity-id", "participant-03", + "--display-name", "Participant Three", + "--private-key-out", "private/participant-03.private.hex", + "--public-identity-out", "participant-03.identity.json", + }, + command: CommandIdentityGenerate, + }, { name: "init", args: []string{ diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index ce54b09..7a09ae0 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -31,6 +31,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com switch invocation.Command { case CommandInit: return executeInit(invocation.Options.(InitOptions)) + case CommandIdentityGenerate: + return executeIdentityGenerate(invocation.Options.(IdentityGenerateOptions)) case CommandRehearsalInit: return executeRehearsalInit(invocation.Options.(RehearsalInitOptions)) case CommandInspect: diff --git a/cmd/mpc-ceremony/identity.go b/cmd/mpc-ceremony/identity.go new file mode 100644 index 0000000..1b200de --- /dev/null +++ b/cmd/mpc-ceremony/identity.go @@ -0,0 +1,132 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + + "proof-tool/internal/mpcceremony" +) + +const generatedIdentityKeyIDPrefix = "ed25519:" + +func executeIdentityGenerate(options IdentityGenerateOptions) (CommandResult, error) { + privateTarget, err := resolvedFreshTarget(options.PrivateKeyOut) + if err != nil { + return CommandResult{}, fmt.Errorf("private key output: %w", err) + } + publicTarget, err := resolvedFreshTarget(options.PublicIdentityOut) + if err != nil { + return CommandResult{}, fmt.Errorf("public identity output: %w", err) + } + if privateTarget == publicTarget { + return CommandResult{}, errors.New("private and public output paths must be distinct") + } + if err := requireFreshTarget(options.PrivateKeyOut); err != nil { + return CommandResult{}, fmt.Errorf("private key output: %w", err) + } + if err := requireFreshTarget(options.PublicIdentityOut); err != nil { + return CommandResult{}, fmt.Errorf("public identity output: %w", err) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return CommandResult{}, fmt.Errorf("generate Ed25519 key from operating-system CSPRNG: %w", err) + } + defer zeroBytes(privateKey) + + publicKeyDigest := sha256.Sum256(publicKey) + identity, err := mpcceremony.NewIdentity( + options.IdentityID, + options.DisplayName, + generatedIdentityKeyIDPrefix+hex.EncodeToString(publicKeyDigest[:]), + publicKey, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("create public ceremony identity: %w", err) + } + publicIdentity, err := mpcceremony.MarshalCanonical(identity) + if err != nil { + return CommandResult{}, fmt.Errorf("encode public ceremony identity: %w", err) + } + + seed := privateKey.Seed() + defer zeroBytes(seed) + privateSeedHex := make([]byte, hex.EncodedLen(len(seed))+1) + hex.Encode(privateSeedHex, seed) + privateSeedHex[len(privateSeedHex)-1] = '\n' + defer zeroBytes(privateSeedHex) + + // Write the non-secret artifact first. A late private-file collision can + // leave an unusable public identity, but it can never strand private key + // material or cause an existing file to be overwritten. + if err := writeFreshOperationalFile(options.PublicIdentityOut, publicIdentity, 0o644); err != nil { + return CommandResult{}, err + } + if err := syncDirectory(filepath.Dir(options.PublicIdentityOut)); err != nil { + return CommandResult{}, fmt.Errorf("sync public identity directory: %w", err) + } + if err := writeFreshOperationalFile(options.PrivateKeyOut, privateSeedHex, 0o600); err != nil { + return CommandResult{}, fmt.Errorf( + "write private key (public identity was created but must not be enrolled): %w", + err, + ) + } + if err := syncDirectory(filepath.Dir(options.PrivateKeyOut)); err != nil { + return CommandResult{}, fmt.Errorf("sync private key directory: %w", err) + } + + return CommandResult{ + Identity: &identity, + Outputs: map[string]string{ + "private_key_SECRET": options.PrivateKeyOut, + "public_identity": options.PublicIdentityOut, + }, + Summary: "generated Ed25519 ceremony identity; keep private_key_SECRET local and share only public_identity", + }, nil +} + +func resolvedFreshTarget(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return "", fmt.Errorf("resolve parent directory: %w", err) + } + info, err := os.Stat(parent) + if err != nil { + return "", fmt.Errorf("inspect parent directory: %w", err) + } + if !info.IsDir() { + return "", errors.New("parent is not a directory") + } + return filepath.Join(parent, filepath.Base(absolute)), nil +} + +func requireFreshTarget(path string) error { + _, err := os.Lstat(path) + switch { + case err == nil: + return errors.New("output already exists") + case errors.Is(err, os.ErrNotExist): + return nil + default: + return err + } +} + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} diff --git a/cmd/mpc-ceremony/identity_test.go b/cmd/mpc-ceremony/identity_test.go new file mode 100644 index 0000000..8f74f2a --- /dev/null +++ b/cmd/mpc-ceremony/identity_test.go @@ -0,0 +1,215 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "proof-tool/internal/keybundle" + "proof-tool/internal/mpcceremony" +) + +func TestIdentityGenerateCreatesCompatibleProtectedKeyAndCanonicalPublicIdentity(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "participant-03.private.hex") + publicPath := filepath.Join(root, "participant-03.identity.json") + args := []string{ + "identity", "generate", + "--identity-id", "participant-03", + "--display-name", "Participant Three", + "--private-key-out", privatePath, + "--public-identity-out", publicPath, + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("identity generate exit = %d, stderr = %q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q", stderr.String()) + } + + privateInfo, err := os.Lstat(privatePath) + if err != nil { + t.Fatal(err) + } + if !privateInfo.Mode().IsRegular() { + t.Fatalf("private output mode = %s, want regular file", privateInfo.Mode()) + } + if runtime.GOOS != "windows" && privateInfo.Mode().Perm() != 0o600 { + t.Fatalf("private output permissions = %o, want 600", privateInfo.Mode().Perm()) + } + + publicIdentityBytes, err := os.ReadFile(publicPath) + if err != nil { + t.Fatal(err) + } + var identity mpcceremony.Identity + if err := mpcceremony.UnmarshalCanonical(publicIdentityBytes, &identity); err != nil { + t.Fatalf("public identity is not canonical: %v", err) + } + if identity.ID != "participant-03" || identity.DisplayName != "Participant Three" { + t.Fatalf("identity = %+v", identity) + } + wantKeyID := generatedIdentityKeyIDPrefix + strings.TrimPrefix(identity.PublicKeyFingerprint, "sha256:") + if identity.KeyID != wantKeyID { + t.Fatalf("key id = %q, want %q", identity.KeyID, wantKeyID) + } + + privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(privatePath) + if err != nil { + t.Fatalf("generated key is not proof-tool-compatible: %v", err) + } + defer zeroBytes(privateKey) + if got := hex.EncodeToString(publicKey); got != identity.Ed25519PublicKeyHex { + t.Fatalf("derived public key = %q, identity has %q", got, identity.Ed25519PublicKeyHex) + } + seedHexBytes, err := os.ReadFile(privatePath) + if err != nil { + t.Fatal(err) + } + seedHex := strings.TrimSpace(string(seedHexBytes)) + zeroBytes(seedHexBytes) + if strings.Contains(stdout.String(), seedHex) { + t.Fatal("human command output disclosed the private seed") + } + for _, want := range []string{ + "key_id: " + identity.KeyID, + "public_key_fingerprint: " + identity.PublicKeyFingerprint, + "private_key_SECRET: " + privatePath, + "public_identity: " + publicPath, + } { + if !strings.Contains(stdout.String(), want) { + t.Errorf("stdout %q does not contain %q", stdout.String(), want) + } + } +} + +func TestIdentityGenerateJSONOutputContainsPublicMetadataButNotPrivateKey(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "auditor-01.private.hex") + publicPath := filepath.Join(root, "auditor-01.identity.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := runCLI(context.Background(), []string{ + "--format", "json", + "identity", "generate", + "--identity-id", "auditor-01", + "--display-name", "Independent Auditor One", + "--private-key-out", privatePath, + "--public-identity-out", publicPath, + }, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("identity generate exit = %d, stderr = %q", code, stderr.String()) + } + + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode JSON result: %v", err) + } + if !result.OK || result.Command != CommandIdentityGenerate || result.Identity == nil { + t.Fatalf("result = %+v", result) + } + if result.Identity.ID != "auditor-01" || result.Identity.KeyID == "" { + t.Fatalf("public identity result = %+v", result.Identity) + } + privateBytes, err := os.ReadFile(privatePath) + if err != nil { + t.Fatal(err) + } + privateHex := strings.TrimSpace(string(privateBytes)) + zeroBytes(privateBytes) + if strings.Contains(stdout.String(), privateHex) { + t.Fatal("JSON command output disclosed the private seed") + } +} + +func TestIdentityGenerateDoesNotOverwriteOrCreatePartialSecretOutput(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "identity.private.hex") + publicPath := filepath.Join(root, "identity.json") + existing := []byte("already enrolled") + if err := os.WriteFile(publicPath, existing, 0o600); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runCLI(context.Background(), []string{ + "identity", "generate", + "--identity-id", "participant-03", + "--display-name", "Participant Three", + "--private-key-out", privatePath, + "--public-identity-out", publicPath, + }, &stdout, &stderr, workflowExecutor{}) + if code == 0 { + t.Fatal("identity generate overwrote an existing output") + } + got, err := os.ReadFile(publicPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, existing) { + t.Fatalf("existing public output changed to %q", got) + } + if _, err := os.Lstat(privatePath); !os.IsNotExist(err) { + t.Fatalf("private output exists after preflight failure: %v", err) + } +} + +func TestIdentityGenerateRejectsSameResolvedOutput(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o700); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(root, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlink unavailable: %v", err) + } + t.Fatal(err) + } + + _, err := executeIdentityGenerate(IdentityGenerateOptions{ + IdentityID: "participant-03", + DisplayName: "Participant Three", + PrivateKeyOut: filepath.Join(realDir, "same"), + PublicIdentityOut: filepath.Join(aliasDir, "same"), + }) + if err == nil || !strings.Contains(err.Error(), "must be distinct") { + t.Fatalf("same resolved output error = %v", err) + } + if _, err := os.Lstat(filepath.Join(realDir, "same")); !os.IsNotExist(err) { + t.Fatalf("same output exists after rejection: %v", err) + } +} + +func TestIdentityGenerateValidatesIdentityBeforeWriting(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "identity.private.hex") + publicPath := filepath.Join(root, "identity.json") + _, err := executeIdentityGenerate(IdentityGenerateOptions{ + IdentityID: "Participant-03", + DisplayName: "Participant Three", + PrivateKeyOut: privatePath, + PublicIdentityOut: publicPath, + }) + if err == nil { + t.Fatal("invalid identity id was accepted") + } + for _, path := range []string{privatePath, publicPath} { + if _, statErr := os.Lstat(path); !os.IsNotExist(statErr) { + t.Fatalf("output %s exists after validation failure: %v", path, statErr) + } + } +} diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 1e03db8..d95ab06 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -17,6 +17,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { topics := [][]string{ nil, {"init"}, + {"identity"}, + {"identity", "generate"}, {"rehearsal"}, {"rehearsal", "init"}, {"phase1"}, @@ -118,6 +120,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--closure", "--created-at", "--destroyed-at", + "--display-name", "--decision", "--draft", "--enrollment", @@ -130,6 +133,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--finalized-at", "--format", "--full", + "--identity-id", "--key-version", "--keys-dir", "--manifest-public-key-file", @@ -145,6 +149,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--prepared-at", "--publication-location", "--public-evidence", + "--public-identity-out", "--participants", "--phase1-beacon", "--phase1-beacon-signature", @@ -162,6 +167,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--phase2-close", "--phase2-close-signature", "--policy", + "--private-key-out", "--quiet", "--raw-response", "--release-dir", @@ -235,6 +241,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { commands := [][]string{ {"init"}, + {"identity", "generate"}, {"phase1", "contribute"}, {"phase1", "attest-erasure"}, {"phase1", "verify"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index edbbd9b..50d84b6 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -79,6 +79,18 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut return 6 } } + if result.Identity != nil { + if _, err := fmt.Fprintf( + stdout, + "identity_id: %s\nkey_id: %s\npublic_key_fingerprint: %s\n", + result.Identity.ID, + result.Identity.KeyID, + result.Identity.PublicKeyFingerprint, + ); err != nil { + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) + return 6 + } + } names := make([]string, 0, len(result.Outputs)) for name := range result.Outputs { names = append(names, name) diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index c098738..9dad6a9 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -67,6 +67,8 @@ func parseInvocation(args []string) (Invocation, error) { options, err := parseInit(rest[1:]) invocation.Command, invocation.Options = CommandInit, options return invocation, wrapCommandError(err, "init") + case "identity": + return parseIdentity(invocation, rest[1:]) case "rehearsal": return parseRehearsal(invocation, rest[1:]) case "inspect": @@ -99,6 +101,47 @@ func parseInvocation(args []string) (Invocation, error) { } } +func parseIdentity(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing identity command", topic: []string{"identity"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"identity"}, args[1:]...)} + } + switch args[0] { + case "generate": + options, err := parseIdentityGenerate(args[1:]) + invocation.Command, invocation.Options = CommandIdentityGenerate, options + return invocation, wrapCommandError(err, "identity", "generate") + default: + return Invocation{}, &usageError{ + message: fmt.Sprintf("unknown identity command %q", args[0]), + topic: []string{"identity"}, + } + } +} + +func parseIdentityGenerate(args []string) (IdentityGenerateOptions, error) { + var options IdentityGenerateOptions + fs := commandFlagSet("identity generate") + fs.StringVar(&options.IdentityID, "identity-id", "", "stable ceremony role identity") + fs.StringVar(&options.DisplayName, "display-name", "", "human-readable identity name") + fs.StringVar(&options.PrivateKeyOut, "private-key-out", "", "fresh secret Ed25519 seed file") + fs.StringVar(&options.PublicIdentityOut, "public-identity-out", "", "fresh public identity JSON file") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := requireValues( + value("--identity-id", options.IdentityID), + value("--display-name", options.DisplayName), + pathValue("--private-key-out", options.PrivateKeyOut), + pathValue("--public-identity-out", options.PublicIdentityOut), + ); err != nil { + return options, err + } + return options, nil +} + func parseRehearsal(invocation Invocation, args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, &usageError{message: "missing rehearsal command", topic: []string{"rehearsal"}} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 1ea3cbd..3282ce9 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -16,6 +16,7 @@ type Command string const ( CommandInit Command = "init" + CommandIdentityGenerate Command = "identity generate" CommandRehearsalInit Command = "rehearsal init" CommandInspect Command = "inspect" CommandPhase1Contribute Command = "phase1 contribute" @@ -60,6 +61,13 @@ type Invocation struct { Options any } +type IdentityGenerateOptions struct { + IdentityID string + DisplayName string + PrivateKeyOut string + PublicIdentityOut string +} + type InitOptions struct { SessionNonceHex string CreatedAt string @@ -419,6 +427,7 @@ type CommandResult struct { SourceTagObjectSHA256 string `json:"source_tag_object_sha256,omitempty"` Outputs map[string]string `json:"outputs,omitempty"` Summary string `json:"summary,omitempty"` + Identity *mpcceremony.Identity `json:"identity,omitempty"` DefinitionInspection *DefinitionInspection `json:"definition_inspection,omitempty"` ChainInspection *ChainInspection `json:"chain_inspection,omitempty"` ParticipantInspection *ParticipantInspection `json:"participant_inspection,omitempty"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 7af6d66..a94e293 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -23,13 +23,15 @@ const rootHelp = `Usage: mpc-ceremony [--format human|json] [--quiet] [flags] Offline, append-only orchestration for this repository's BLS12-381 Groth16 -multi-party setup. Production commands accept operator-supplied artifacts and -signing keys only; the explicitly rehearsal-only initializer creates same-host -test identities. The binary performs no network access and never selects a -mutable "latest" artifact. +multi-party setup. Identity generation is the only production command that +creates a signing key; all operational commands accept an existing local key. +The explicitly rehearsal-only initializer creates same-host test identities. +The binary performs no network access and never selects a mutable "latest" +artifact. Commands: init Bind a ceremony to the compiled repository circuit + identity generate Create a local Ed25519 key and public identity document rehearsal init Create and initialize a three-party tiny rehearsal inspect Report chain state and next scheduled contribution phase1 contribute Verify the full phase 1 chain and contribute @@ -108,6 +110,28 @@ second path list. ` var commandHelp = map[string]string{ + "identity": `Usage: + mpc-ceremony identity generate --identity-id ID --display-name NAME \ + --private-key-out FRESH_SECRET_FILE \ + --public-identity-out FRESH_PUBLIC_FILE + +Generate an Ed25519 ceremony signing identity from operating-system CSPRNG +entropy. Run "mpc-ceremony help identity generate" for handling rules. +`, + "identity generate": `Usage: + mpc-ceremony identity generate --identity-id ID --display-name NAME \ + --private-key-out FRESH_SECRET_FILE \ + --public-identity-out FRESH_PUBLIC_FILE + +Generates a new Ed25519 key using the operating-system CSPRNG. The private +output is a proof-tool-compatible hex seed created with mode 0600; keep it on +the trusted machine and never send it to Relay or the coordinator. The public +output is canonical identity JSON containing the public key, its SHA-256 +fingerprint, and an automatically derived key ID. Share only that public file. + +Both parent directories must already exist, both output paths must be distinct, +and neither output may already exist. Private key bytes are never printed. +`, "rehearsal": `Usage: mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR