From 06410b63abd1d1786119946bd68079acea50a347 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 13 Aug 2026 08:47:17 +0000 Subject: [PATCH 01/64] 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 00000000..0d33d80e --- /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 3a21165a..fb0fb24f 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/64] 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 d6c4025f..66440372 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/64] 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 f1901942..d22e5bd3 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 f684a68d..cfcf817c 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 72e071a6..f2b8e447 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/64] 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 00000000..8c49d310 --- /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 00000000..0bb22564 --- /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 00000000..b1d64d25 --- /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/64] 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 f153380d..2725242a 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 4ed520f8..e12e4a3d 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/64] 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 035455db..25bed80b 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/64] 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 45cbd3f0..2f6502fc 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 06dc7115..cf000f5a 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 00000000..bbc0a66b --- /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 14d738bb..10cc397d 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 c2b3d986..e4bf4f4a 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 a918ba32..8b7041b5 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 25bed80b..af31fd44 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/64] 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 fc84bcfe..9e1082d9 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 476b7c55..1546c814 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 f2b8e447..8c20063e 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/64] 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 00000000..f9263a0d --- /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 fb0fb24f..b908030a 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/64] 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 00000000..92a07841 --- /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/64] 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 d22e5bd3..a43d3a1e 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 75f83ce8..b6503f6a 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 9127a577..cdc108ff 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 edfa89d9..9da59492 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 56d88220..56f4e7b1 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 0bb22564..d7ea1f5e 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 00000000..d65cb622 --- /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 e12e4a3d..a543831a 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 fee59799..85702acd 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 8c20063e..b794f952 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/64] 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 a43d3a1e..d66a7db5 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 d7ea1f5e..8771dc53 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 90c5f63c..cba20ca9 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 b794f952..b8e2fe6e 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/64] 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 d66a7db5..6febf74a 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 8771dc53..bde27487 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 cba20ca9..e6a67f36 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 b8e2fe6e..2be0d01f 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/64] 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 00000000..cf352184 --- /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 cde64685..e702336b 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/64] 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 0ea52ee1..cc80991d 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 00000000..626f24f7 --- /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/64] 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 6febf74a..9b1d7084 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 cc80991d..42202121 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 cdc108ff..f3ebcfd7 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 9da59492..29a636ef 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 56f4e7b1..c6197f15 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 00000000..56a027b2 --- /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 00000000..3d9b4efe --- /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 e6a67f36..84367af1 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/64] 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 8c49d310..cee39c24 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 bde27487..00000000 --- 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/64] 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 d65cb622..07741b54 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 a543831a..e8f172b9 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 ddc0065e..48e5f4e2 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 38766759..d574fde5 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 cd39d223..194ea5ec 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 2be0d01f..bccf0604 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/64] 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 f3ebcfd7..447de824 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 c6197f15..fd5ec25c 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 7d982761..aceb6a86 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 66440372..6de302af 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 c227d066..5276f3b6 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 00000000..b73144ff --- /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 b908030a..4eb29a59 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/64] 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 6de302af..c51f8712 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/64] 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 8952b509..0f30b82c 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 9b1d7084..c6bb6542 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 00000000..60ad5a84 --- /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 00000000..c80913e2 --- /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 b6503f6a..efac782c 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 42202121..1d1c6872 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 7d3ecae9..c8ec8ad7 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 447de824..e28d5126 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 00000000..c7fd29d1 --- /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 29a636ef..6cbb7f22 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 fd5ec25c..d3400e03 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 00000000..d936b521 --- /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 00000000..3dd1bef8 --- /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 00000000..34f7f52b --- /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 ab8080d2..53f171cd 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 db7cb598..c8021164 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 49e2a310..590ef729 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 bccf0604..d22ef33b 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/64] 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 92a07841..783ce451 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/64] 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 2f6502fc..ccee9778 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 00000000..9a40c83a --- /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 cf000f5a..83a9082c 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 bbc0a66b..2122fcf4 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 00000000..d8f6a794 --- /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 00000000..7e7019e2 --- /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 10cc397d..1b33a51d 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 e4bf4f4a..2798f48d 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/64] 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 00000000..974f347f --- /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 cee39c24..9cfebd62 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 00000000..353f7ec2 --- /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/64] 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 d22ef33b..5159eeaf 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/64] 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 0347f76c..fc796450 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/64] 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 974f347f..d0d0e703 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 0f30b82c..3239e91a 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 c6bb6542..e4843b1f 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 efac782c..1e03db85 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 1d1c6872..edbbd9b6 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 e28d5126..c098738a 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 00000000..3c2fa711 --- /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 00000000..163343ff --- /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 6cbb7f22..1ea3cbdd 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 d3400e03..8684621b 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 00000000..8fbb522c --- /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 194ea5ec..abad5d0b 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 4eb29a59..aa15c5c0 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 96f50d05..406e7efd 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 00000000..9916fa47 --- /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 00000000..dfe87546 --- /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 fda685d2..f4651d89 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/64] 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 c156cff8..1a33ee9e 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 4666463c..227ebc71 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 08196178..ee7a18fd 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 00000000..10fc976b --- /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 00000000..894210b5 --- /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 00000000..96443cf9 --- /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 00000000..1d3a82cf --- /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 44558ab1..3eb76267 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 8401c2c4..a04dd410 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 c74e846e..02e59839 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 9c640d40..2cffa995 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/64] 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 e702336b..a84e7e7e 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 00000000..1344c469 --- /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 21509e00..71a77ee3 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/64] 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 6779f8c1..c00e2993 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 974f347f..438db502 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/64] 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 9cfebd62..00000000 --- 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/64] 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 353f7ec2..00000000 --- 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 783ce451..00000000 --- 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/64] 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 294b5463..0be05387 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/64] 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 8684621b..7af6d66e 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/64] 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 438db502..2f2bf113 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/64] 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 57955111..f4fe178c 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 00000000..daea4171 --- /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 debbe884..68c93817 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/64] 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 ee7a18fd..18728e60 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 68c93817..1874c7b9 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/64] 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 daea4171..8abe771c 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/64] 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 e4843b1f..b344b577 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 10fc976b..a91a917b 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 00000000..c066837d --- /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 00000000..f53a717b --- /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/64] 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 f4fe178c..b2d5e9c1 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 75878ca7..37b8e358 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 8ea4d33d..7754d2ac 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 00000000..8900fbfa --- /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 5494212b..7735378a 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 b344b577..ce54b094 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 dfbbb9b6..616bca20 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 a341d7fb..629e64c7 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 72a2f714..b1c353ad 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 c066837d..940b9d8a 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 abad5d0b..22d1f467 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 91618ad3..24ee2583 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 aa15c5c0..8f1d261a 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 8e95b89c..65014506 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 f5ba0259..d6f81cd7 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 47def260..e7283e8b 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 24258cf4..370b4eb8 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 2cffa995..f28f5622 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 7dbf6d8d..c3de2f7b 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/64] 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 370b4eb8..cad4c1cd 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 f28f5622..d253626c 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/64] 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 3239e91a..29174de7 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 ce54b094..7a09ae07 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 00000000..1b200de3 --- /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 00000000..8f74f2a2 --- /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 1e03db85..d95ab068 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 edbbd9b6..50d84b64 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 c098738a..9dad6a9d 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 1ea3cbdd..3282ce9b 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 7af6d66e..a94e293b 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 From bc67dd8b2cbfe870c7b89107fe46934839fb035e Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 4 Sep 2026 17:59:01 +0900 Subject: [PATCH 43/64] feat: allow exact multi-platform ceremony binaries --- .../e2e/mainnet/prepare-reclaim-mainnet.mjs | 10 +- .../mainnet/prepare-reclaim-mainnet.test.mjs | 2 +- cmd/mpc-ceremony/executor.go | 9 + cmd/mpc-ceremony/integration_test.go | 1 + cmd/mpc-ceremony/parse.go | 16 ++ cmd/mpc-ceremony/rehearsal.go | 1 + cmd/mpc-ceremony/types.go | 6 +- cmd/mpc-ceremony/usage.go | 12 +- docs/mpc-ceremony-release.md | 16 +- docs/trusted-setup-ceremony.md | 8 + internal/mpcceremony/chain.go | 2 +- internal/mpcceremony/definition.go | 49 +++- internal/mpcceremony/definition_test.go | 18 ++ internal/mpcceremony/model.go | 135 +++++++++-- internal/mpcceremony/operational.go | 2 +- internal/mpcceremony/software.go | 217 ++++++++++++++---- internal/mpcceremony/software_test.go | 75 ++++++ internal/mpcceremony/workflow.go | 10 +- scripts/build-mpc-ceremony-release.sh | 88 ++++++- scripts/verify-mpc-build-metadata/main.go | 46 +++- scripts/verify-mpc-ceremony-reproducible.sh | 34 ++- 21 files changed, 665 insertions(+), 92 deletions(-) diff --git a/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.mjs b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.mjs index 19174979..186465e3 100644 --- a/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.mjs +++ b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.mjs @@ -559,7 +559,15 @@ export function inspectMPCRelease({ "MPC final transcript must bind at least two independent audits.", ); } - exact(ceremony.schema, "proof-tool-mpc-ceremony-definition-v1", "ceremony definition schema"); + if ( + ceremony.schema !== "proof-tool-mpc-ceremony-definition-v1" && + ceremony.schema !== "proof-tool-mpc-ceremony-definition-v2" + ) { + throw new MainnetPreparationError( + "coherence_mismatch", + "ceremony definition schema does not match a supported value.", + ); + } exact(ceremony.ceremony_id, expectedCeremonyID, "ceremony definition id"); exact(ceremony.mode, "production", "ceremony mode"); exact(ceremony.software?.source_commit, expectedSourceCommit, "ceremony source commit"); diff --git a/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.test.mjs b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.test.mjs index 73b5f8ec..67c085a2 100644 --- a/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.test.mjs +++ b/apps/ownership-proof-web/e2e/mainnet/prepare-reclaim-mainnet.test.mjs @@ -467,7 +467,7 @@ function releaseFixture() { vk_hash: nativeDigest.blake2b256, }; const ceremony = { - schema: "proof-tool-mpc-ceremony-definition-v1", + schema: "proof-tool-mpc-ceremony-definition-v2", ceremony_id: ceremonyID, mode: "production", software: { source_commit: sourceCommit, source_dirty: false }, diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 7a09ae07..ee7f3635 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -120,6 +120,15 @@ func executeInit(options InitOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } + runningSoftware, err = mpcceremony.SoftwareBindingWithAllowedBinaryFiles( + runningSoftware, + proofToolVersion, + options.Mode, + options.AllowedBinaryPaths, + ) + if err != nil { + return CommandResult{}, err + } nonce, err := sessionNonce(options.SessionNonceHex) if err != nil { return CommandResult{}, err diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index d95ab068..7bb9af87 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -193,6 +193,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--witness-enrollment", "--witness-enrollment-signature", "--accepted-at", + "--allowed-binary", "--contributed-at", } flagPattern := regexp.MustCompile(`--[a-z0-9-]+`) diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 9dad6a9d..08834e24 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -164,12 +164,20 @@ func parseRehearsal(invocation Invocation, args []string) (Invocation, error) { func parseRehearsalInit(args []string) (RehearsalInitOptions, error) { var options RehearsalInitOptions + var allowedBinaries stringList 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") + fs.Var(&allowedBinaries, "allowed-binary", "additional exact mpc-ceremony binary to sign into the platform allowlist (repeatable)") if err := parseFlags(fs, args); err != nil { return options, err } + options.AllowedBinaryPaths = append([]string(nil), allowedBinaries...) + for _, path := range options.AllowedBinaryPaths { + if err := validatePathValue("--allowed-binary", path); err != nil { + return options, err + } + } return options, requireValues( value("--created-at", options.CreatedAt), pathValue("--out-dir", options.OutDir), @@ -687,6 +695,7 @@ func parseRelease(invocation Invocation, args []string) (Invocation, error) { func parseInit(args []string) (InitOptions, error) { var options InitOptions + var allowedBinaries stringList 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") @@ -697,9 +706,16 @@ func parseInit(args []string) (InitOptions, error) { fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path") fs.StringVar(&options.OutDir, "out-dir", "", "fresh ceremony directory") fs.StringVar(&options.Mode, "mode", "rehearsal", "ceremony mode: rehearsal or production") + fs.Var(&allowedBinaries, "allowed-binary", "additional exact mpc-ceremony binary to sign into the platform allowlist (repeatable)") if err := parseFlags(fs, args); err != nil { return options, err } + options.AllowedBinaryPaths = append([]string(nil), allowedBinaries...) + for _, path := range options.AllowedBinaryPaths { + if err := validatePathValue("--allowed-binary", path); err != nil { + return options, err + } + } if options.Mode != "rehearsal" && options.Mode != "production" { return options, errors.New("--mode must be rehearsal or production") } diff --git a/cmd/mpc-ceremony/rehearsal.go b/cmd/mpc-ceremony/rehearsal.go index 3c2fa711..bb03c31b 100644 --- a/cmd/mpc-ceremony/rehearsal.go +++ b/cmd/mpc-ceremony/rehearsal.go @@ -48,6 +48,7 @@ func executeRehearsalInit(options RehearsalInitOptions) (result CommandResult, e CoordinatorSigningKey: filepath.Join(keyRoot, "coordinator.ed25519.private.hex"), OutDir: filepath.Join(options.OutDir, "public"), Mode: mpcceremony.ModeRehearsal, + AllowedBinaryPaths: options.AllowedBinaryPaths, }) if err != nil { return CommandResult{}, err diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 3282ce9b..739d6a8d 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -78,11 +78,13 @@ type InitOptions struct { CoordinatorSigningKey string OutDir string Mode string + AllowedBinaryPaths []string } type RehearsalInitOptions struct { - CreatedAt string - OutDir string + CreatedAt string + OutDir string + AllowedBinaryPaths []string } type ContributeOptions struct { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index a94e293b..9992170b 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -133,13 +133,15 @@ 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 + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR \ + [--allowed-binary FILE ...] 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 + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR \ + [--allowed-binary FILE ...] Creates fresh same-host identities and canonical configuration for exactly three participants, then initializes a signed rehearsal-tiny-v1 ceremony. The @@ -193,12 +195,14 @@ the identity, role, role index, timestamp, and independence disclosure. --participants ROSTER.json --policy POLICY.json \ --coordinator-key-id ID --coordinator-signing-key KEY \ --created-at RFC3339 --out-dir DIR [--mode rehearsal|production] \ - [--session-nonce-hex HEX] + [--session-nonce-hex HEX] [--allowed-binary FILE ...] Compiles a registered repository circuit and writes a fresh signed ceremony definition. The authoritative ceremony ID is derived from canonical content, including a 32-byte session nonce securely generated when omitted. Production -mode requires an exact clean source build. +mode requires exact clean source builds. The running binary is always allowed; +each repeated --allowed-binary adds one authenticated binary for another +platform to the signed definition. `, "phase1": `Usage: mpc-ceremony phase1 [flags] diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index 8abe771c..e88e3914 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -10,7 +10,8 @@ following proof-tool properties: - the approved Go toolchain and module identity; - the patched vendor tree; -- two byte-for-byte reproducible unsigned rehearsal packages; +- two byte-for-byte reproducible unsigned rehearsal packages containing the + canonical Linux/amd64 binary and its Linux/arm64 counterpart; - the downloadable tiny rehearsal initializer and authenticated definition projection; and - absence of production signatures from rehearsal packages. @@ -18,8 +19,17 @@ following proof-tool properties: 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. +offline build-signing key. Publish both `mpc-ceremony` (Linux/amd64) and +`mpc-ceremony-linux-arm64`, together with their complete verification package, +through proof-tool's release process. + +New ceremony definitions use schema v2. The coordinator runs either released +binary and passes the other with repeated `--allowed-binary FILE` flags during +`init` (or `rehearsal init`). Initialization reads the embedded Go build +metadata and rejects different source commits, dependency versions, Go +versions, compiler/build policies, dirty states, or multiple binaries for one +platform. The signed definition records the full exact-digest allowlist; +legacy v1 definitions remain one-binary ceremonies. ## Coordinated distribution diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index 1874c7b9..f7d71720 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -17,6 +17,14 @@ 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). +A v2 ceremony may authorize both released Linux CPU targets in one signed +definition. The running binary is included automatically; the coordinator adds +the other exact executable at initialization with `--allowed-binary FILE`. +Every participant still verifies that its current executable is an exact +allowlist member, and every contribution attestation records the digest that +actually ran. A v1 definition is intentionally interpreted as a singleton +allowlist. + ## Single-Actor Local Setup Run the local path with: diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index e8f172b9..dfb4e207 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -351,7 +351,7 @@ func ValidateAttestationAcceptance( if !ok || participant.Identity.KeyID != attestation.ParticipantKeyID { return errors.New("attestation participant identity does not match definition") } - if definition.Software.ToolBinary != attestation.ToolBinary || + if !definition.Software.AllowsToolBinary(attestation.ToolBinary) || definition.Software.SourceCommit != attestation.SourceCommit || definition.Software.GnarkVersion != attestation.GnarkVersion || definition.Software.GnarkCryptoVersion != attestation.GnarkCryptoVersion || diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index 22d1f467..e970dbfa 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -57,13 +57,17 @@ type DefinitionOptions struct { } func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error) { + software := options.Software + if len(software.Binaries) == 0 { + software.Binaries = []SoftwareBinary{software.primaryBinary()} + } definition := CeremonyDefinition{ Schema: DefinitionSchema, Mode: options.Mode, CreatedAt: options.CreatedAt, SessionNonceHex: options.SessionNonceHex, Circuit: options.Circuit, - Software: options.Software, + Software: software, Coordinator: options.Coordinator, ReleaseSigner: options.ReleaseSigner, Auditors: append([]Identity(nil), options.Auditors...), @@ -89,6 +93,9 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error // compilation from metadata construction. func FinalizeCeremonyDefinition(definition CeremonyDefinition) (CeremonyDefinition, error) { definition.Schema = DefinitionSchema + if len(definition.Software.Binaries) == 0 { + definition.Software.Binaries = []SoftwareBinary{definition.Software.primaryBinary()} + } definition.CeremonyID = "" id, err := ComputeCeremonyID(definition) if err != nil { @@ -106,7 +113,11 @@ func ComputeCeremonyID(definition CeremonyDefinition) (string, error) { if err := definition.validate(false); err != nil { return "", err } - return canonicalHash("proof-tool/mpc-ceremony/root/v1", definition) + domain := "proof-tool/mpc-ceremony/root/v2" + if definition.Schema == DefinitionSchemaV1 { + domain = "proof-tool/mpc-ceremony/root/v1" + } + return canonicalHash(domain, definition) } func (d CeremonyDefinition) Validate() error { @@ -124,8 +135,17 @@ func (d CeremonyDefinition) Validate() error { } func (d CeremonyDefinition) validate(requireID bool) error { - if d.Schema != DefinitionSchema { - return fmt.Errorf("definition schema %q, want %q", d.Schema, DefinitionSchema) + switch d.Schema { + case DefinitionSchema: + case DefinitionSchemaV1: + if len(d.Software.Binaries) != 0 || d.Software.GoARM64 != "" { + return errors.New("definition v1 must not contain v2 software fields") + } + default: + return fmt.Errorf( + "definition schema %q, want %q or %q", + d.Schema, DefinitionSchemaV1, DefinitionSchema, + ) } if requireID { if err := validateTaggedHex(d.CeremonyID, "sha256:", 32); err != nil { @@ -159,6 +179,7 @@ func (d CeremonyDefinition) validate(requireID bool) error { d.Software.GoOS, d.Software.GoArch, d.Software.GoAMD64, + d.Software.GoARM64, d.Software.Compiler, d.Software.BuildMode, d.Software.CGOEnabled, @@ -186,6 +207,26 @@ func (d CeremonyDefinition) validate(requireID bool) error { if err := d.Software.Validate(); err != nil { return fmt.Errorf("software: %w", err) } + if d.Schema == DefinitionSchema && len(d.Software.Binaries) == 0 { + return errors.New("definition v2 requires at least one allowed software binary") + } + if d.Mode == ModeProduction { + for index, binary := range d.Software.AllowedBinaries() { + if err := validateProductionBuildProfile( + d.Software.GoVersion, + binary.GoOS, + binary.GoArch, + binary.GoAMD64, + binary.GoARM64, + d.Software.Compiler, + d.Software.BuildMode, + d.Software.CGOEnabled, + d.Software.TrimPath, + ); err != nil { + return fmt.Errorf("production software binary %d profile: %w", index, err) + } + } + } if err := d.Coordinator.Validate(); err != nil { return fmt.Errorf("coordinator: %w", err) } diff --git a/internal/mpcceremony/definition_test.go b/internal/mpcceremony/definition_test.go index 24ee2583..5047b1fe 100644 --- a/internal/mpcceremony/definition_test.go +++ b/internal/mpcceremony/definition_test.go @@ -5,6 +5,24 @@ import ( "testing" ) +func TestDefinitionV1RemainsAValidSingletonBinaryPolicy(t *testing.T) { + definition := adversarialDefinition(t) + definition.Schema = DefinitionSchemaV1 + definition.Software.Binaries = nil + definition.CeremonyID = "" + id, err := ComputeCeremonyID(definition) + if err != nil { + t.Fatal(err) + } + definition.CeremonyID = id + if err := definition.Validate(); err != nil { + t.Fatalf("legacy definition rejected: %v", err) + } + if got := definition.Software.AllowedBinaries(); len(got) != 1 || got[0] != definition.Software.primaryBinary() { + t.Fatalf("legacy binary policy = %#v", got) + } +} + func TestProductionDefinitionRequiresCanonicalDestinationCircuit(t *testing.T) { tests := []struct { name string diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index 8f1d261a..cb2bc9e0 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -19,7 +19,8 @@ import ( ) const ( - DefinitionSchema = "proof-tool-mpc-ceremony-definition-v1" + DefinitionSchemaV1 = "proof-tool-mpc-ceremony-definition-v1" + DefinitionSchema = "proof-tool-mpc-ceremony-definition-v2" DetachedSignatureSchema = "proof-tool-mpc-detached-signature-v1" ContributionAttestationSchema = "proof-tool-mpc-contribution-attestation-v1" ErasureAttestationSchema = "proof-tool-mpc-erasure-attestation-v1" @@ -47,6 +48,7 @@ const ( ProductionGOOS = "linux" ProductionGOARCH = "amd64" ProductionGOAMD64 = "v1" + ProductionGOARM64 = "v8.0" ProductionCompiler = "gc" ProductionBuildMode = "exe" SignatureAlgorithm = "Ed25519" @@ -268,21 +270,99 @@ func (b CircuitBinding) Validate() error { } type SoftwareBinding struct { - ProofToolVersion string `json:"proof_tool_version"` - GnarkVersion string `json:"gnark_version"` - GnarkCryptoVersion string `json:"gnark_crypto_version"` - DrandVersion string `json:"drand_version"` - GoVersion string `json:"go_version"` - GoOS string `json:"goos"` - GoArch string `json:"goarch"` - GoAMD64 string `json:"goamd64,omitempty"` - Compiler string `json:"compiler"` - BuildMode string `json:"build_mode"` - CGOEnabled bool `json:"cgo_enabled"` - TrimPath bool `json:"trimpath"` - SourceCommit string `json:"source_commit"` - SourceDirty bool `json:"source_dirty"` - ToolBinary Digest `json:"tool_binary"` + ProofToolVersion string `json:"proof_tool_version"` + GnarkVersion string `json:"gnark_version"` + GnarkCryptoVersion string `json:"gnark_crypto_version"` + DrandVersion string `json:"drand_version"` + GoVersion string `json:"go_version"` + GoOS string `json:"goos"` + GoArch string `json:"goarch"` + GoAMD64 string `json:"goamd64,omitempty"` + GoARM64 string `json:"goarm64,omitempty"` + Compiler string `json:"compiler"` + BuildMode string `json:"build_mode"` + CGOEnabled bool `json:"cgo_enabled"` + TrimPath bool `json:"trimpath"` + SourceCommit string `json:"source_commit"` + SourceDirty bool `json:"source_dirty"` + ToolBinary Digest `json:"tool_binary"` + Binaries []SoftwareBinary `json:"binaries,omitempty"` +} + +// SoftwareBinary binds one supported execution platform to the exact +// mpc-ceremony executable bytes approved by the signed definition. The +// top-level GoOS/GoArch/variant and ToolBinary fields remain the canonical +// primary entry so v1 definitions can be interpreted as singleton policies. +type SoftwareBinary struct { + GoOS string `json:"goos"` + GoArch string `json:"goarch"` + GoAMD64 string `json:"goamd64,omitempty"` + GoARM64 string `json:"goarm64,omitempty"` + ToolBinary Digest `json:"tool_binary"` +} + +func (b SoftwareBinary) Validate() error { + if strings.TrimSpace(b.GoOS) == "" { + return errors.New("goos is required") + } + if strings.TrimSpace(b.GoArch) == "" { + return errors.New("goarch is required") + } + switch b.GoArch { + case "amd64": + if strings.TrimSpace(b.GoAMD64) == "" { + return errors.New("goamd64 is required for amd64 binaries") + } + if b.GoARM64 != "" { + return errors.New("goarm64 must be empty for amd64 binaries") + } + case "arm64": + if strings.TrimSpace(b.GoARM64) == "" { + return errors.New("goarm64 is required for arm64 binaries") + } + if b.GoAMD64 != "" { + return errors.New("goamd64 must be empty for arm64 binaries") + } + default: + if b.GoAMD64 != "" || b.GoARM64 != "" { + return errors.New("architecture variant must be empty for unsupported architectures") + } + } + if err := b.ToolBinary.Validate(); err != nil { + return fmt.Errorf("tool_binary: %w", err) + } + return nil +} + +func (b SoftwareBinary) platformKey() string { + return b.GoOS + "/" + b.GoArch + "/" + b.GoAMD64 + "/" + b.GoARM64 +} + +func (b SoftwareBinding) primaryBinary() SoftwareBinary { + return SoftwareBinary{ + GoOS: b.GoOS, GoArch: b.GoArch, GoAMD64: b.GoAMD64, GoARM64: b.GoARM64, + ToolBinary: b.ToolBinary, + } +} + +// AllowedBinaries returns the signed exact-binary policy. Legacy v1 software +// bindings have no Binaries field and are treated as a singleton policy. +func (b SoftwareBinding) AllowedBinaries() []SoftwareBinary { + if len(b.Binaries) == 0 { + return []SoftwareBinary{b.primaryBinary()} + } + return append([]SoftwareBinary(nil), b.Binaries...) +} + +// AllowsToolBinary reports whether a digest is one of the exact binaries in +// the signed policy. Contribution attestations already bind this full digest. +func (b SoftwareBinding) AllowsToolBinary(digest Digest) bool { + for _, allowed := range b.AllowedBinaries() { + if allowed.ToolBinary == digest { + return true + } + } + return false } func (b SoftwareBinding) Validate() error { @@ -310,6 +390,15 @@ func (b SoftwareBinding) Validate() error { if b.GoArch == ProductionGOARCH && strings.TrimSpace(b.GoAMD64) == "" { return errors.New("goamd64 is required for amd64 binaries") } + if b.GoArch == "arm64" && strings.TrimSpace(b.GoARM64) == "" { + return errors.New("goarm64 is required for arm64 binaries") + } + if b.GoArch != "amd64" && b.GoAMD64 != "" { + return errors.New("goamd64 must be empty for non-amd64 binaries") + } + if b.GoArch != "arm64" && b.GoARM64 != "" { + return errors.New("goarm64 must be empty for non-arm64 binaries") + } if strings.TrimSpace(b.Compiler) == "" { return errors.New("compiler is required") } @@ -322,6 +411,20 @@ func (b SoftwareBinding) Validate() error { if err := b.ToolBinary.Validate(); err != nil { return fmt.Errorf("tool_binary: %w", err) } + if len(b.Binaries) > 8 { + return errors.New("binaries exceed maximum 8") + } + for index, binary := range b.Binaries { + if err := binary.Validate(); err != nil { + return fmt.Errorf("binaries %d: %w", index, err) + } + if index > 0 && b.Binaries[index-1].platformKey() >= binary.platformKey() { + return errors.New("binaries must be strictly sorted by platform with no duplicates") + } + } + if len(b.Binaries) > 0 && b.Binaries[0] != b.primaryBinary() { + return errors.New("top-level software binary must equal the first canonical binaries entry") + } return nil } diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index 53f171cd..666de88c 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -1070,7 +1070,7 @@ func identityOverlapsDefinition(definition CeremonyDefinition, candidate Identit func verifyTransferSource(definition CeremonyDefinition, source TransferSourceBinding) error { if source.SourceCommit != definition.Software.SourceCommit || - source.ToolBinary != definition.Software.ToolBinary || + !definition.Software.AllowsToolBinary(source.ToolBinary) || source.R1CS != definition.Circuit.R1CS { return errors.New("transfer source, binary, or R1CS binding does not match ceremony definition") } diff --git a/internal/mpcceremony/software.go b/internal/mpcceremony/software.go index df39e165..ef687de1 100644 --- a/internal/mpcceremony/software.go +++ b/internal/mpcceremony/software.go @@ -2,6 +2,7 @@ package mpcceremony import ( "crypto/sha256" + "debug/buildinfo" "encoding/hex" "errors" "fmt" @@ -9,6 +10,7 @@ import ( "os" "runtime" "runtime/debug" + "sort" "strconv" "strings" @@ -68,6 +70,143 @@ func RunningSoftwareBindingForMode(proofToolVersion, mode string) (SoftwareBindi return runningSoftwareBinding(proofToolVersion, mode, productionSoftwareSource()) } +// SoftwareBindingWithAllowedBinaryFiles authenticates additional +// mpc-ceremony executables and returns one canonical, platform-keyed policy. +// Every binary must have identical source and dependency metadata; exactly one +// binary is permitted for each platform and architecture variant. +func SoftwareBindingWithAllowedBinaryFiles( + primary SoftwareBinding, + proofToolVersion string, + mode string, + paths []string, +) (SoftwareBinding, error) { + bindings := make([]SoftwareBinding, 0, len(paths)) + for index, path := range paths { + file, err := os.Open(path) + if err != nil { + return SoftwareBinding{}, fmt.Errorf("open allowed binary %d %q: %w", index, path, err) + } + info, err := buildinfo.Read(file) + if err != nil { + file.Close() + return SoftwareBinding{}, fmt.Errorf("read allowed binary %d %q build info: %w", index, path, err) + } + if info.Path != "proof-tool/cmd/mpc-ceremony" { + file.Close() + return SoftwareBinding{}, fmt.Errorf( + "allowed binary %d %q main package %q, want %q", + index, path, info.Path, "proof-tool/cmd/mpc-ceremony", + ) + } + fdPath, err := openFileDescriptorPath(file) + if err != nil { + file.Close() + return SoftwareBinding{}, fmt.Errorf("allowed binary %d %q: %w", index, path, err) + } + source := runningSoftwareSource{ + executable: func() (string, error) { return fdPath, nil }, + readBuildInfo: func() (*debug.BuildInfo, bool) { return info, true }, + runtimeVersion: func() string { return info.GoVersion }, + } + binding, bindingErr := runningSoftwareBinding(proofToolVersion, mode, source) + closeErr := file.Close() + if bindingErr != nil { + return SoftwareBinding{}, fmt.Errorf("authenticate allowed binary %d %q: %w", index, path, bindingErr) + } + if closeErr != nil { + return SoftwareBinding{}, fmt.Errorf("close allowed binary %d %q: %w", index, path, closeErr) + } + if err := requireCommonSoftwareIdentity(primary, binding); err != nil { + return SoftwareBinding{}, fmt.Errorf("allowed binary %d %q: %w", index, path, err) + } + bindings = append(bindings, binding) + } + return softwareBindingWithAllowedBindings(primary, bindings) +} + +func softwareBindingWithAllowedBindings( + primary SoftwareBinding, + additional []SoftwareBinding, +) (SoftwareBinding, error) { + if err := primary.Validate(); err != nil { + return SoftwareBinding{}, fmt.Errorf("primary software binding: %w", err) + } + for index, binding := range additional { + if err := binding.Validate(); err != nil { + return SoftwareBinding{}, fmt.Errorf("allowed software binding %d: %w", index, err) + } + if err := requireCommonSoftwareIdentity(primary, binding); err != nil { + return SoftwareBinding{}, fmt.Errorf("allowed software binding %d: %w", index, err) + } + } + binaries := primary.AllowedBinaries() + for _, binding := range additional { + binaries = append(binaries, binding.primaryBinary()) + } + sort.Slice(binaries, func(i, j int) bool { + return binaries[i].platformKey() < binaries[j].platformKey() + }) + for index := 1; index < len(binaries); index++ { + if binaries[index-1].platformKey() == binaries[index].platformKey() { + return SoftwareBinding{}, fmt.Errorf( + "multiple allowed binaries target platform %s", + binaries[index].platformKey(), + ) + } + } + result := primary + result.GoOS = binaries[0].GoOS + result.GoArch = binaries[0].GoArch + result.GoAMD64 = binaries[0].GoAMD64 + result.GoARM64 = binaries[0].GoARM64 + result.ToolBinary = binaries[0].ToolBinary + result.Binaries = binaries + if err := result.Validate(); err != nil { + return SoftwareBinding{}, fmt.Errorf("allowed software policy: %w", err) + } + return result, nil +} + +func openFileDescriptorPath(file *os.File) (string, error) { + switch runtime.GOOS { + case "linux": + return "/proc/self/fd/" + strconv.FormatUint(uint64(file.Fd()), 10), nil + case "darwin": + return "/dev/fd/" + strconv.FormatUint(uint64(file.Fd()), 10), nil + default: + return "", fmt.Errorf("secure allowed-binary inspection is unsupported on %s", runtime.GOOS) + } +} + +func requireCommonSoftwareIdentity(expected, actual SoftwareBinding) error { + switch { + case expected.ProofToolVersion != actual.ProofToolVersion: + return softwareMismatch("proof_tool_version", expected.ProofToolVersion, actual.ProofToolVersion) + case expected.GnarkVersion != actual.GnarkVersion: + return softwareMismatch("gnark_version", expected.GnarkVersion, actual.GnarkVersion) + case expected.GnarkCryptoVersion != actual.GnarkCryptoVersion: + return softwareMismatch("gnark_crypto_version", expected.GnarkCryptoVersion, actual.GnarkCryptoVersion) + case expected.DrandVersion != actual.DrandVersion: + return softwareMismatch("drand_version", expected.DrandVersion, actual.DrandVersion) + case expected.GoVersion != actual.GoVersion: + return softwareMismatch("go_version", expected.GoVersion, actual.GoVersion) + case expected.Compiler != actual.Compiler: + return softwareMismatch("compiler", expected.Compiler, actual.Compiler) + case expected.BuildMode != actual.BuildMode: + return softwareMismatch("build_mode", expected.BuildMode, actual.BuildMode) + case expected.CGOEnabled != actual.CGOEnabled: + return softwareMismatch("cgo_enabled", expected.CGOEnabled, actual.CGOEnabled) + case expected.TrimPath != actual.TrimPath: + return softwareMismatch("trimpath", expected.TrimPath, actual.TrimPath) + case expected.SourceCommit != actual.SourceCommit: + return softwareMismatch("source_commit", expected.SourceCommit, actual.SourceCommit) + case expected.SourceDirty != actual.SourceDirty: + return softwareMismatch("source_dirty", expected.SourceDirty, actual.SourceDirty) + default: + return nil + } +} + // VerifyRunningSoftware fails unless every field in expected describes the // exact clean binary that is currently running. func VerifyRunningSoftware(expected SoftwareBinding) error { @@ -132,12 +271,19 @@ func runningSoftwareBinding( return SoftwareBinding{}, err } goAMD64 := "" - if goArch == ProductionGOARCH { + if goArch == "amd64" { goAMD64, err = uniqueBuildSetting(buildInfo, "GOAMD64") if err != nil { return SoftwareBinding{}, err } } + goARM64 := "" + if goArch == "arm64" { + goARM64, err = uniqueBuildSetting(buildInfo, "GOARM64") + if err != nil { + return SoftwareBinding{}, err + } + } compiler, err := uniqueBuildSetting(buildInfo, "-compiler") if err != nil { return SoftwareBinding{}, err @@ -160,6 +306,7 @@ func runningSoftwareBinding( goOS, goArch, goAMD64, + goARM64, compiler, buildMode, cgoEnabled, @@ -253,6 +400,7 @@ func runningSoftwareBinding( GoOS: goOS, GoArch: goArch, GoAMD64: goAMD64, + GoARM64: goARM64, Compiler: compiler, BuildMode: buildMode, CGOEnabled: cgoEnabled, @@ -261,6 +409,7 @@ func runningSoftwareBinding( SourceDirty: sourceDirty, ToolBinary: toolBinary, } + binding.Binaries = []SoftwareBinary{binding.primaryBinary()} if err := binding.Validate(); err != nil { return SoftwareBinding{}, fmt.Errorf("derived software binding: %w", err) } @@ -289,48 +438,19 @@ func verifyRunningSoftware( if err != nil { return fmt.Errorf("derive running software binding: %w", err) } - switch { - case expected.ProofToolVersion != actual.ProofToolVersion: - return softwareMismatch("proof_tool_version", expected.ProofToolVersion, actual.ProofToolVersion) - case expected.GnarkVersion != actual.GnarkVersion: - return softwareMismatch("gnark_version", expected.GnarkVersion, actual.GnarkVersion) - case expected.GnarkCryptoVersion != actual.GnarkCryptoVersion: - return softwareMismatch("gnark_crypto_version", expected.GnarkCryptoVersion, actual.GnarkCryptoVersion) - case expected.DrandVersion != actual.DrandVersion: - return softwareMismatch("drand_version", expected.DrandVersion, actual.DrandVersion) - case expected.GoVersion != actual.GoVersion: - return softwareMismatch("go_version", expected.GoVersion, actual.GoVersion) - case expected.GoOS != actual.GoOS: - return softwareMismatch("goos", expected.GoOS, actual.GoOS) - case expected.GoArch != actual.GoArch: - return softwareMismatch("goarch", expected.GoArch, actual.GoArch) - case expected.GoAMD64 != actual.GoAMD64: - return softwareMismatch("goamd64", expected.GoAMD64, actual.GoAMD64) - case expected.Compiler != actual.Compiler: - return softwareMismatch("compiler", expected.Compiler, actual.Compiler) - case expected.BuildMode != actual.BuildMode: - return softwareMismatch("build_mode", expected.BuildMode, actual.BuildMode) - case expected.CGOEnabled != actual.CGOEnabled: - return softwareMismatch("cgo_enabled", expected.CGOEnabled, actual.CGOEnabled) - case expected.TrimPath != actual.TrimPath: - return softwareMismatch("trimpath", expected.TrimPath, actual.TrimPath) - case expected.SourceCommit != actual.SourceCommit: - return softwareMismatch("source_commit", expected.SourceCommit, actual.SourceCommit) - case expected.SourceDirty != actual.SourceDirty: - return softwareMismatch("source_dirty", expected.SourceDirty, actual.SourceDirty) - case expected.ToolBinary.SHA256 != actual.ToolBinary.SHA256: - return softwareMismatch("tool_binary.sha256", expected.ToolBinary.SHA256, actual.ToolBinary.SHA256) - case expected.ToolBinary.Blake2b256 != actual.ToolBinary.Blake2b256: - return softwareMismatch( - "tool_binary.blake2b256", - expected.ToolBinary.Blake2b256, - actual.ToolBinary.Blake2b256, - ) - case expected.ToolBinary.Size != actual.ToolBinary.Size: - return softwareMismatch("tool_binary.size", expected.ToolBinary.Size, actual.ToolBinary.Size) - default: - return nil + if err := requireCommonSoftwareIdentity(expected, actual); err != nil { + return err + } + actualBinary := actual.primaryBinary() + for _, allowed := range expected.AllowedBinaries() { + if allowed == actualBinary { + return nil + } } + return fmt.Errorf( + "running software binary %s (%s) is not in the signed allowlist", + actualBinary.platformKey(), actualBinary.ToolBinary.SHA256, + ) } func digestRunningExecutable(path string) (Digest, error) { @@ -435,6 +555,7 @@ func validateProductionBuildProfile( goOS string, goArch string, goAMD64 string, + goARM64 string, compiler string, buildMode string, cgoEnabled bool, @@ -445,10 +566,16 @@ func validateProductionBuildProfile( return softwareMismatch("go_version", ProductionGoVersion, goVersion) case goOS != ProductionGOOS: return softwareMismatch("goos", ProductionGOOS, goOS) - case goArch != ProductionGOARCH: - return softwareMismatch("goarch", ProductionGOARCH, goArch) - case goAMD64 != ProductionGOAMD64: + case goArch != "amd64" && goArch != "arm64": + return fmt.Errorf("running software goarch %q, want %q or %q", goArch, "amd64", "arm64") + case goArch == "amd64" && goAMD64 != ProductionGOAMD64: return softwareMismatch("goamd64", ProductionGOAMD64, goAMD64) + case goArch == "amd64" && goARM64 != "": + return softwareMismatch("goarm64", "", goARM64) + case goArch == "arm64" && goARM64 != ProductionGOARM64: + return softwareMismatch("goarm64", ProductionGOARM64, goARM64) + case goArch == "arm64" && goAMD64 != "": + return softwareMismatch("goamd64", "", goAMD64) case compiler != ProductionCompiler: return softwareMismatch("compiler", ProductionCompiler, compiler) case buildMode != ProductionBuildMode: diff --git a/internal/mpcceremony/software_test.go b/internal/mpcceremony/software_test.go index d6f81cd7..0bff9c78 100644 --- a/internal/mpcceremony/software_test.go +++ b/internal/mpcceremony/software_test.go @@ -352,6 +352,67 @@ func TestRehearsalBindingRecordsAndVerifiesDirtyBuild(t *testing.T) { } } +func TestPlatformBinaryAllowlistAcceptsExactAMD64AndARM64Executables(t *testing.T) { + amdSource := newTestSoftwareSource(t, []byte("amd64 ceremony executable"), testBuildInfo()) + amdBinding, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, amdSource) + if err != nil { + t.Fatal(err) + } + armSource := newTestSoftwareSource(t, []byte("arm64 ceremony executable"), testARM64BuildInfo()) + armBinding, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, armSource) + if err != nil { + t.Fatal(err) + } + + policy, err := softwareBindingWithAllowedBindings(armBinding, []SoftwareBinding{amdBinding}) + if err != nil { + t.Fatal(err) + } + if len(policy.Binaries) != 2 { + t.Fatalf("allowed binaries = %d, want 2", len(policy.Binaries)) + } + if policy.Binaries[0].GoArch != "amd64" || policy.Binaries[1].GoArch != "arm64" { + t.Fatalf("allowed binaries are not canonical: %#v", policy.Binaries) + } + if err := verifyRunningSoftware(policy, ModeProduction, amdSource); err != nil { + t.Fatalf("verify allowed amd64 binary: %v", err) + } + if err := verifyRunningSoftware(policy, ModeProduction, armSource); err != nil { + t.Fatalf("verify allowed arm64 binary: %v", err) + } + + unlistedSource := newTestSoftwareSource(t, []byte("unlisted amd64 executable"), testBuildInfo()) + if err := verifyRunningSoftware(policy, ModeProduction, unlistedSource); err == nil { + t.Fatal("unlisted binary was accepted") + } +} + +func TestPlatformBinaryAllowlistRejectsMultipleBinariesForOnePlatformAndMetadataDrift(t *testing.T) { + primarySource := newTestSoftwareSource(t, []byte("primary"), testBuildInfo()) + primary, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, primarySource) + if err != nil { + t.Fatal(err) + } + duplicateSource := newTestSoftwareSource(t, []byte("different bytes"), testBuildInfo()) + duplicate, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, duplicateSource) + if err != nil { + t.Fatal(err) + } + if _, err := softwareBindingWithAllowedBindings(primary, []SoftwareBinding{duplicate}); err == nil { + t.Fatal("two binaries for one platform were accepted") + } + + armSource := newTestSoftwareSource(t, []byte("arm64"), testARM64BuildInfo()) + arm, err := runningSoftwareBinding(prover.ProofToolVersion, ModeProduction, armSource) + if err != nil { + t.Fatal(err) + } + arm.SourceCommit = strings.Repeat("a", 40) + if _, err := softwareBindingWithAllowedBindings(primary, []SoftwareBinding{arm}); err == nil { + t.Fatal("different source commit was accepted") + } +} + func TestRunningSoftwareBindingRejectsWrongCompiledVersionAndBadExecutable(t *testing.T) { source := newTestSoftwareSource(t, []byte("ceremony executable"), testBuildInfo()) if _, err := runningSoftwareBinding(prover.ProofToolVersion, "unknown", source); err == nil { @@ -455,6 +516,20 @@ func testBuildInfo() *debug.BuildInfo { } } +func testARM64BuildInfo() *debug.BuildInfo { + info := testBuildInfo() + settings := info.Settings[:0] + for _, setting := range info.Settings { + if setting.Key != "GOAMD64" { + settings = append(settings, setting) + } + } + info.Settings = settings + setTestBuildSetting(info, "GOARCH", "arm64") + setTestBuildSetting(info, "GOARM64", ProductionGOARM64) + return info +} + func newTestSoftwareSource( t *testing.T, executable []byte, diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 5159eeaf..d686f3a4 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -54,6 +54,7 @@ type TrustPaths struct { type TrustedCeremony struct { Definition CeremonyDefinition CoordinatorPublicKey ed25519.PublicKey + RunningSoftware SoftwareBinding } // InitParticipants is the fixed-field, canonical enrollment input accepted by @@ -243,6 +244,13 @@ func loadOperationalCeremony(paths TrustPaths) (*TrustedCeremony, error) { ); err != nil { return nil, fmt.Errorf("running software does not match signed ceremony definition: %w", err) } + trusted.RunningSoftware, err = RunningSoftwareBindingForMode( + trusted.Definition.Software.ProofToolVersion, + trusted.Definition.Mode, + ) + if err != nil { + return nil, fmt.Errorf("record running software identity: %w", err) + } return trusted, nil } @@ -783,7 +791,7 @@ func CreateContributionCandidate(options ContributionFilesOptions) (result Contr PreviousPayload: previousPayload, OutputPayload: outputRef, PreviousAcceptanceID: previousRecordID, - ToolBinary: trusted.Definition.Software.ToolBinary, + ToolBinary: trusted.RunningSoftware.ToolBinary, SourceCommit: trusted.Definition.Software.SourceCommit, GnarkVersion: trusted.Definition.Software.GnarkVersion, GnarkCryptoVersion: trusted.Definition.Software.GnarkCryptoVersion, diff --git a/scripts/build-mpc-ceremony-release.sh b/scripts/build-mpc-ceremony-release.sh index cad4c1cd..cbdbf737 100755 --- a/scripts/build-mpc-ceremony-release.sh +++ b/scripts/build-mpc-ceremony-release.sh @@ -294,6 +294,31 @@ env \ -o "$STAGING/mpc-ceremony" \ ./cmd/mpc-ceremony +env \ + -u GOROOT \ + -u GOAMD64 \ + CGO_ENABLED=0 \ + GOCACHE="$CANONICAL_ROOT/go-cache" \ + GOENV=off \ + GOEXPERIMENT= \ + GOFIPS140=off \ + GOOS=linux \ + GOARCH=arm64 \ + GOARM64=v8.0 \ + GOTOOLCHAIN=local \ + GOWORK=off \ + GOFLAGS= \ + SOURCE_DATE_EPOCH="$SOURCE_DATE_EPOCH" \ + TZ=UTC \ + LC_ALL=C \ + "$GO_BIN" build \ + -mod=vendor \ + -trimpath \ + -buildvcs=true \ + -ldflags=-buildid= \ + -o "$STAGING/mpc-ceremony-linux-arm64" \ + ./cmd/mpc-ceremony + env \ -u GOROOT \ CGO_ENABLED=0 \ @@ -336,6 +361,25 @@ env \ -build-flags "$BUILD_FLAGS" \ "$STAGING/mpc-ceremony" >"$STAGING/binary-manifest.json" +env \ + -u GOROOT \ + -u GOARM64 \ + CGO_ENABLED=0 \ + GOCACHE="$CANONICAL_ROOT/go-cache" \ + GOENV=off \ + GOEXPERIMENT= \ + GOFIPS140=off \ + GOTOOLCHAIN=local \ + GOWORK=off \ + GOOS=linux \ + GOARCH=amd64 \ + GOAMD64=v1 \ + GOFLAGS=-mod=vendor \ + "$GO_BIN" run ./scripts/hash-blake2b \ + -go-version "$GO_VERSION" \ + -build-flags "$BUILD_FLAGS" \ + "$STAGING/mpc-ceremony-linux-arm64" >"$STAGING/arm64-binary-manifest.json" + env \ -u GOROOT \ CGO_ENABLED=0 \ @@ -372,6 +416,25 @@ env \ --name mpc-ceremony \ --source-root "$CANONICAL_SOURCE" >"$STAGING/sbom.cdx.json" +env \ + -u GOROOT \ + -u GOARM64 \ + CGO_ENABLED=0 \ + GOCACHE="$CANONICAL_ROOT/go-cache" \ + GOENV=off \ + GOEXPERIMENT= \ + GOFIPS140=off \ + GOTOOLCHAIN=local \ + GOWORK=off \ + GOOS=linux \ + GOARCH=amd64 \ + GOAMD64=v1 \ + GOFLAGS=-mod=vendor \ + "$GO_BIN" run ./scripts/generate-go-sbom \ + --binary "$STAGING/mpc-ceremony-linux-arm64" \ + --name mpc-ceremony-linux-arm64 \ + --source-root "$CANONICAL_SOURCE" >"$STAGING/arm64-sbom.cdx.json" + env \ -u GOROOT \ CGO_ENABLED=0 \ @@ -402,8 +465,8 @@ env \ ( cd "$STAGING" - sha256sum mpc-ceremony mpc-finalization-evidence >checksums.sha256 - b2sum -l 256 mpc-ceremony mpc-finalization-evidence >checksums.blake2b256 + sha256sum mpc-ceremony mpc-ceremony-linux-arm64 mpc-finalization-evidence >checksums.sha256 + b2sum -l 256 mpc-ceremony mpc-ceremony-linux-arm64 mpc-finalization-evidence >checksums.blake2b256 env -u GOROOT \ CGO_ENABLED=0 \ GOARCH=amd64 \ @@ -414,6 +477,16 @@ env \ GOTOOLCHAIN=local \ GOAMD64=v1 \ "$GO_BIN" version -m ./mpc-ceremony >go-build-info.txt + env -u GOROOT -u GOAMD64 \ + CGO_ENABLED=0 \ + GOARCH=arm64 \ + GOENV=off \ + GOEXPERIMENT= \ + GOFIPS140=off \ + GOOS=linux \ + GOARM64=v8.0 \ + GOTOOLCHAIN=local \ + "$GO_BIN" version -m ./mpc-ceremony-linux-arm64 >arm64-go-build-info.txt env -u GOROOT \ CGO_ENABLED=0 \ GOARCH=amd64 \ @@ -440,6 +513,9 @@ $EXPECTED_ASM_SHA256 asm EOF ROOT_INPUTS=( + "$STAGING/arm64-binary-manifest.json" + "$STAGING/arm64-go-build-info.txt" + "$STAGING/arm64-sbom.cdx.json" "$STAGING/binary-manifest.json" "$STAGING/build-mode.txt" "$STAGING/checksums.blake2b256" @@ -450,6 +526,7 @@ ROOT_INPUTS=( "$STAGING/finalization-evidence-sbom.cdx.json" "$STAGING/mpc-finalization-evidence" "$STAGING/mpc-ceremony" + "$STAGING/mpc-ceremony-linux-arm64" "$STAGING/sbom.cdx.json" "$STAGING/signed-tag-object.txt" "$STAGING/signed-tag-signer-fingerprint.txt" @@ -503,7 +580,10 @@ if [[ -n "$BUILD_SIGNING_KEY" ]]; then --public-key-out "$STAGING/build-package-manifest-public-key.hex" fi -chmod 0555 "$STAGING/mpc-ceremony" "$STAGING/mpc-finalization-evidence" +chmod 0555 \ + "$STAGING/mpc-ceremony" \ + "$STAGING/mpc-ceremony-linux-arm64" \ + "$STAGING/mpc-finalization-evidence" chmod 0444 \ "$STAGING"/*.txt \ "$STAGING"/*.json \ @@ -535,4 +615,4 @@ rm -rf -- "$CANONICAL_ROOT" CANONICAL_ROOT= trap - EXIT -echo "OK: built $OUT_DIR/mpc-ceremony from $SOURCE_COMMIT ($MODE)" +echo "OK: built linux/amd64 and linux/arm64 mpc-ceremony binaries from $SOURCE_COMMIT ($MODE)" diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index d253626c..8cf911a9 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -41,6 +41,9 @@ var ( fingerprintPattern = regexp.MustCompile(`^([0-9A-F]{40}|[0-9A-F]{64})$`) lowerSHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) rootFileNames = []string{ + "arm64-binary-manifest.json", + "arm64-go-build-info.txt", + "arm64-sbom.cdx.json", "binary-manifest.json", "build-mode.txt", "checksums.blake2b256", @@ -51,6 +54,7 @@ var ( "finalization-evidence-sbom.cdx.json", "mpc-finalization-evidence", "mpc-ceremony", + "mpc-ceremony-linux-arm64", "sbom.cdx.json", "signed-tag-object.txt", "signed-tag-signer-fingerprint.txt", @@ -155,6 +159,13 @@ func main() { if err := verifyBinaryManifest(*dir, ceremonyManifest, "mpc-ceremony"); err != nil { fatal(err) } + arm64Manifest, err := readDigestManifest(filepath.Join(*dir, "arm64-binary-manifest.json")) + if err != nil { + fatal(err) + } + if err := verifyBinaryManifest(*dir, arm64Manifest, "mpc-ceremony-linux-arm64"); err != nil { + fatal(err) + } evidenceManifest, err := readDigestManifest( filepath.Join(*dir, "finalization-evidence-binary-manifest.json"), ) @@ -164,13 +175,21 @@ func main() { if err := verifyBinaryManifest(*dir, evidenceManifest, "mpc-finalization-evidence"); err != nil { fatal(err) } - if err := verifyBinaryChecksums(*dir, ceremonyManifest.Files[0], evidenceManifest.Files[0]); err != nil { + if err := verifyBinaryChecksums( + *dir, + ceremonyManifest.Files[0], + arm64Manifest.Files[0], + evidenceManifest.Files[0], + ); err != nil { + fatal(err) + } + if err := verifyBuildInfo(filepath.Join(*dir, "mpc-ceremony"), *commit, "amd64"); err != nil { fatal(err) } - if err := verifyBuildInfo(filepath.Join(*dir, "mpc-ceremony"), *commit); err != nil { + if err := verifyBuildInfo(filepath.Join(*dir, "mpc-ceremony-linux-arm64"), *commit, "arm64"); err != nil { fatal(err) } - if err := verifyBuildInfo(filepath.Join(*dir, "mpc-finalization-evidence"), *commit); err != nil { + if err := verifyBuildInfo(filepath.Join(*dir, "mpc-finalization-evidence"), *commit, "amd64"); err != nil { fatal(err) } if err := verifySBOM( @@ -181,6 +200,14 @@ func main() { ); err != nil { fatal(err) } + if err := verifySBOM( + filepath.Join(*dir, "arm64-sbom.cdx.json"), + *sourceRoot, + *commit, + "mpc-ceremony-linux-arm64", + ); err != nil { + fatal(err) + } if err := verifySBOM( filepath.Join(*dir, "finalization-evidence-sbom.cdx.json"), *sourceRoot, @@ -294,7 +321,7 @@ func verifyBinaryChecksums(dir string, entries ...digestEntry) error { return nil } -func verifyBuildInfo(path, commit string) error { +func verifyBuildInfo(path, commit, architecture string) error { info, err := buildinfo.ReadFile(path) if err != nil { return err @@ -307,13 +334,20 @@ func verifyBuildInfo(path, commit string) error { "-compiler": "gc", "-trimpath": "true", "CGO_ENABLED": "0", - "GOARCH": "amd64", + "GOARCH": architecture, "GOOS": "linux", - "GOAMD64": "v1", "vcs": "git", "vcs.modified": "false", "vcs.revision": commit, } + switch architecture { + case "amd64": + expected["GOAMD64"] = "v1" + case "arm64": + expected["GOARM64"] = "v8.0" + default: + return fmt.Errorf("unsupported binary architecture %q", architecture) + } for key, value := range expected { actual, err := uniqueSetting(info, key) if err != nil { diff --git a/scripts/verify-mpc-ceremony-reproducible.sh b/scripts/verify-mpc-ceremony-reproducible.sh index c3de2f7b..1d0b3a22 100755 --- a/scripts/verify-mpc-ceremony-reproducible.sh +++ b/scripts/verify-mpc-ceremony-reproducible.sh @@ -87,6 +87,9 @@ fi BUILD_A=$1 BUILD_B=$2 EXPECTED_FILES=( + arm64-binary-manifest.json + arm64-go-build-info.txt + arm64-sbom.cdx.json binary-manifest.json build-mode.txt build-package-manifest.json @@ -99,6 +102,7 @@ EXPECTED_FILES=( go-build-info.txt mpc-finalization-evidence mpc-ceremony + mpc-ceremony-linux-arm64 sbom.cdx.json signed-tag-object.txt signed-tag-signer-fingerprint.txt @@ -190,7 +194,8 @@ for dir in "$BUILD_A" "$BUILD_B"; do exit 1 fi expected_mode=444 - if [[ "$name" == "mpc-ceremony" || "$name" == "mpc-finalization-evidence" ]]; then + if [[ "$name" == "mpc-ceremony" || "$name" == "mpc-ceremony-linux-arm64" || + "$name" == "mpc-finalization-evidence" ]]; then expected_mode=555 fi actual_mode=$(stat -c %a "$dir/$name") @@ -199,8 +204,9 @@ for dir in "$BUILD_A" "$BUILD_B"; do exit 1 fi done - if [[ ! -x "$dir/mpc-ceremony" || ! -x "$dir/mpc-finalization-evidence" ]]; then - echo "FAIL: both release binaries must be executable: $dir" >&2 + if [[ ! -x "$dir/mpc-ceremony" || ! -x "$dir/mpc-ceremony-linux-arm64" || + ! -x "$dir/mpc-finalization-evidence" ]]; then + echo "FAIL: all release binaries must be executable: $dir" >&2 exit 1 fi if [[ "$MODE" == "production" ]]; then @@ -251,6 +257,26 @@ for dir in "$BUILD_A" "$BUILD_B"; do exit 1 fi rm -f -- "$BUILD_INFO_TMP" + ARM64_BUILD_INFO_TMP=$(mktemp "${TMPDIR:-/tmp}/mpc-arm64-build-info.XXXXXXXX") + ( + cd "$dir" + env -u GOROOT -u GOAMD64 \ + CGO_ENABLED=0 \ + GOARCH=arm64 \ + GOENV=off \ + GOEXPERIMENT= \ + GOFIPS140=off \ + GOOS=linux \ + GOARM64=v8.0 \ + GOTOOLCHAIN=local \ + "$GO_BIN" version -m ./mpc-ceremony-linux-arm64 >"$ARM64_BUILD_INFO_TMP" + ) + if ! cmp "$ARM64_BUILD_INFO_TMP" "$dir/arm64-go-build-info.txt"; then + rm -f -- "$ARM64_BUILD_INFO_TMP" + echo "FAIL: saved Go build information does not exactly describe the arm64 binary: $dir" >&2 + exit 1 + fi + rm -f -- "$ARM64_BUILD_INFO_TMP" EVIDENCE_BUILD_INFO_TMP=$(mktemp "${TMPDIR:-/tmp}/mpc-finalization-build-info.XXXXXXXX") ( cd "$dir" @@ -275,6 +301,7 @@ done diff -r --no-dereference "$BUILD_A" "$BUILD_B" cmp "$BUILD_A/mpc-ceremony" "$BUILD_B/mpc-ceremony" +cmp "$BUILD_A/mpc-ceremony-linux-arm64" "$BUILD_B/mpc-ceremony-linux-arm64" cmp "$BUILD_A/mpc-finalization-evidence" "$BUILD_B/mpc-finalization-evidence" if [[ "$MODE" == "production" ]]; then @@ -283,4 +310,5 @@ else echo "OK: independent MPC ceremony rehearsal builds are semantically valid and byte-identical (NOT PRODUCTION)" fi sha256sum "$BUILD_A/mpc-ceremony" +sha256sum "$BUILD_A/mpc-ceremony-linux-arm64" sha256sum "$BUILD_A/mpc-finalization-evidence" From 779bc446f15dbd6dbd2b6c3bed22b4f0f6877766 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:11:11 +0900 Subject: [PATCH 44/64] feat: gate production Mac release on host wipe --- cmd/mpc-ceremony/cli_test.go | 14 ++ cmd/mpc-ceremony/executor.go | 25 +- cmd/mpc-ceremony/inspect.go | 5 +- cmd/mpc-ceremony/inspect_test.go | 3 +- cmd/mpc-ceremony/integration_test.go | 4 + cmd/mpc-ceremony/main.go | 3 +- cmd/mpc-ceremony/ops.go | 27 +++ cmd/mpc-ceremony/parse.go | 28 ++- cmd/mpc-ceremony/types.go | 24 +- cmd/mpc-ceremony/usage.go | 20 +- docs/mpc-ceremony-release.md | 8 + docs/trusted-setup-ceremony.md | 16 ++ internal/mpcceremony/definition.go | 112 +++++---- internal/mpcceremony/host_wipe.go | 222 +++++++++++++++++ internal/mpcceremony/host_wipe_test.go | 224 ++++++++++++++++++ internal/mpcceremony/operational.go | 13 +- internal/mpcceremony/operational_bundle.go | 117 ++++++++- .../mpcceremony/operational_bundle_test.go | 9 + internal/mpcceremony/workflow.go | 22 +- 19 files changed, 820 insertions(+), 76 deletions(-) create mode 100644 internal/mpcceremony/host_wipe.go create mode 100644 internal/mpcceremony/host_wipe_test.go diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 29174de7..1fafa11b 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -345,6 +345,20 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { ), command: CommandDecisionVerify, }, + { + name: "ops attest host wipe", + args: joinArgs( + []string{"ops", "attest-host-wipe"}, + ceremonyTrust, + []string{ + "--participant-id", "participant-01", + "--participant-signing-key", "private/participant-01.key", + "--wiped-at", "2026-09-04T12:00:00Z", + "--out-dir", "ops/host-wipe", + }, + ), + command: CommandOpsAttestHostWipe, + }, { name: "ops prepare public witness receipt", args: joinArgs( diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index ee7f3635..77b0ab69 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -73,6 +73,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeReleaseVerify(invocation.Options.(ReleaseVerifyOptions)) case CommandOpsPreparePublicWitnessReceipt: return executeOpsPreparePublicWitnessReceipt(invocation.Options.(OpsPreparePublicWitnessReceiptOptions)) + case CommandOpsAttestHostWipe: + return executeOpsAttestHostWipe(invocation.Options.(HostWipeOptions)) case CommandOpsPrepareMirrorReceipt: return executeOpsPrepareMirrorReceipt(invocation.Options.(OpsPrepareMirrorReceiptOptions)) case CommandOpsExportSigning: @@ -149,17 +151,18 @@ func executeInit(options InitOptions) (CommandResult, error) { RootDir: options.OutDir, Circuit: circuit, Definition: mpcceremony.DefinitionOptions{ - Mode: options.Mode, - CreatedAt: options.CreatedAt, - SessionNonceHex: nonce, - Software: runningSoftware, - Coordinator: participants.Coordinator, - ReleaseSigner: participants.ReleaseSigner, - Auditors: participants.Auditors, - Roster: participants.Roster, - Phase1Policy: policy.Phase1Policy, - Phase2Policy: policy.Phase2Policy, - BeaconPolicy: policy.BeaconPolicy, + Mode: options.Mode, + CreatedAt: options.CreatedAt, + SessionNonceHex: nonce, + Software: runningSoftware, + Coordinator: participants.Coordinator, + ReleaseSigner: participants.ReleaseSigner, + Auditors: participants.Auditors, + Roster: participants.Roster, + HostWipeParticipants: participants.HostWipeParticipants, + Phase1Policy: policy.Phase1Policy, + Phase2Policy: policy.Phase2Policy, + BeaconPolicy: policy.BeaconPolicy, }, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, }) diff --git a/cmd/mpc-ceremony/inspect.go b/cmd/mpc-ceremony/inspect.go index 60ad5a84..08fe7c87 100644 --- a/cmd/mpc-ceremony/inspect.go +++ b/cmd/mpc-ceremony/inspect.go @@ -145,7 +145,10 @@ func inspectDefinition(definition mpcceremony.CeremonyDefinition) DefinitionInsp Mode: definition.Mode, Phase1Participants: append([]string(nil), definition.Phase1Policy.Participants...), Phase2Participants: append([]string(nil), definition.Phase2Policy.Participants...), - R1CS: definition.Circuit.R1CS, + HostWipeParticipants: append( + []string(nil), definition.HostWipeParticipants..., + ), + R1CS: definition.Circuit.R1CS, } } diff --git a/cmd/mpc-ceremony/inspect_test.go b/cmd/mpc-ceremony/inspect_test.go index c80913e2..39d9f00a 100644 --- a/cmd/mpc-ceremony/inspect_test.go +++ b/cmd/mpc-ceremony/inspect_test.go @@ -88,7 +88,8 @@ func TestInspectCommandsAuthenticateSignedDefinitionAndChain(t *testing.T) { check: func(result CommandResult) bool { return result.DefinitionInspection != nil && result.DefinitionInspection.CeremonyID == definition.CeremonyID && - reflect.DeepEqual(result.DefinitionInspection.Phase1Participants, definition.Phase1Policy.Participants) + reflect.DeepEqual(result.DefinitionInspection.Phase1Participants, definition.Phase1Policy.Participants) && + reflect.DeepEqual(result.DefinitionInspection.HostWipeParticipants, definition.HostWipeParticipants) }, }, { diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 7bb9af87..997a09c7 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -52,6 +52,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"inspect", "participant"}, {"inspect", "enrollment"}, {"ops"}, + {"ops", "attest-host-wipe"}, {"ops", "prepare-public-witness-receipt"}, {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, @@ -195,6 +196,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--accepted-at", "--allowed-binary", "--contributed-at", + "--wiped-at", } flagPattern := regexp.MustCompile(`--[a-z0-9-]+`) seenSet := make(map[string]struct{}) @@ -222,6 +224,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandDecisionPrepare, Options: DecisionPrepareOptions{}}, {Command: CommandDecisionSign, Options: DecisionSignOptions{}}, {Command: CommandDecisionVerify, Options: DecisionVerifyOptions{}}, + {Command: CommandOpsAttestHostWipe, Options: HostWipeOptions{}}, {Command: CommandOpsPreparePublicWitnessReceipt, Options: OpsPreparePublicWitnessReceiptOptions{}}, {Command: CommandOpsPrepareMirrorReceipt, Options: OpsPrepareMirrorReceiptOptions{}}, {Command: CommandInspectDefinition, Options: InspectDefinitionOptions{}}, @@ -266,6 +269,7 @@ func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { {"inspect", "participant"}, {"inspect", "enrollment"}, {"ops", "prepare-public-witness-receipt"}, + {"ops", "attest-host-wipe"}, {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, {"ops", "import-signature"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 50d84b64..0d610f2e 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -274,7 +274,8 @@ command: "chain": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, }, "ops": { - "export-signing": {}, "help": {}, "import-signature": {}, + "attest-host-wipe": {}, + "export-signing": {}, "help": {}, "import-signature": {}, "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "verify": {}, }, "release": {"help": {}, "sign": {}, "verify": {}}, diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index c8ec8ad7..167660cb 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -18,6 +18,33 @@ import ( const maxOperationalRecordBytes = 16 << 20 +func executeOpsAttestHostWipe(options HostWipeOptions) (CommandResult, error) { + result, err := mpcceremony.CreateHostWipeAttestationFiles( + mpcceremony.CreateHostWipeAttestationFilesOptions{ + Trust: mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }, + ParticipantID: options.ParticipantID, + ParticipantPrivateKeyPath: options.ParticipantSigningKey, + WipedAt: options.WipedAt, + OutDir: options.OutDir, + }, + ) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: result.Attestation.CeremonyID, + Summary: "created participant-signed post-wipe macOS host attestation", + Outputs: map[string]string{ + "host_wipe": result.AttestationPath, + "host_wipe_signature": result.SignaturePath, + }, + }, nil +} + func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceiptOptions) (CommandResult, error) { trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ DefinitionPath: options.CeremonyPath, diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 08834e24..d979df2f 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -432,6 +432,10 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)} } switch args[0] { + case "attest-host-wipe": + options, err := parseHostWipe(args[1:]) + invocation.Command, invocation.Options = CommandOpsAttestHostWipe, options + return invocation, wrapCommandError(err, "ops", "attest-host-wipe") case "prepare-public-witness-receipt": options, err := parseOpsPreparePublicWitnessReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPreparePublicWitnessReceipt, options @@ -460,6 +464,28 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { } } +func parseHostWipe(args []string) (HostWipeOptions, error) { + var options HostWipeOptions + fs := commandFlagSet("ops attest-host-wipe") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.ParticipantID, "participant-id", "", "participant identity from the signed host-wipe policy") + fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "participant Ed25519 private key restored from separate storage") + fs.StringVar(&options.WipedAt, "wiped-at", "", "completion time of the whole-device wipe and clean reinstall in RFC3339 UTC") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for the signed host-wipe record") + 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), + value("--participant-id", options.ParticipantID), + pathValue("--participant-signing-key", options.ParticipantSigningKey), + value("--wiped-at", options.WipedAt), + pathValue("--out-dir", options.OutDir), + ) +} + func parseOpsPreparePublicWitnessReceipt(args []string) (OpsPreparePublicWitnessReceiptOptions, error) { var options OpsPreparePublicWitnessReceiptOptions fs := commandFlagSet("ops prepare-public-witness-receipt") @@ -585,7 +611,7 @@ func parseOpsVerify(args []string) (OpsVerifyOptions, error) { } func addOpsRecordFlags(fs *flag.FlagSet, recordType, recordPath *string) { - fs.StringVar(recordType, "record-type", "", "enrollment, handoff, receipt, mirror-receipt, public-witness, beacon-evidence, evidence-bundle, or governance") + fs.StringVar(recordType, "record-type", "", "enrollment, handoff, receipt, mirror-receipt, public-witness, beacon-evidence, evidence-bundle, governance, or host-wipe") fs.StringVar(recordPath, "record", "", "canonical operational record JSON") } diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 739d6a8d..7609199c 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -38,6 +38,7 @@ const ( CommandReleaseVerify Command = "release verify" CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" + CommandOpsAttestHostWipe Command = "ops attest-host-wipe" CommandOpsExportSigning Command = "ops export-signing" CommandOpsImportSig Command = "ops import-signature" CommandOpsVerify Command = "ops verify" @@ -127,6 +128,16 @@ type ErasureOptions struct { DestroyedAt string } +type HostWipeOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + ParticipantID string + ParticipantSigningKey string + WipedAt string + OutDir string +} + type CloseOptions struct { CeremonyPath string CeremonySignaturePath string @@ -320,12 +331,13 @@ type InspectEnrollmentOptions struct { } 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"` + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + Phase1Participants []string `json:"phase1_participants"` + Phase2Participants []string `json:"phase2_participants"` + HostWipeParticipants []string `json:"host_wipe_participants,omitempty"` + R1CS mpcceremony.ArtifactRef `json:"r1cs"` } type ChainRecordInspection struct { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 9992170b..d194ed0e 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -58,6 +58,7 @@ Commands: 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 attest-host-wipe Sign a post-wipe macOS host attestation 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 @@ -202,7 +203,9 @@ definition. The authoritative ceremony ID is derived from canonical content, including a 32-byte session nonce securely generated when omitted. Production mode requires exact clean source builds. The running binary is always allowed; each repeated --allowed-binary adds one authenticated binary for another -platform to the signed definition. +platform to the signed definition. For production Mac contributors, the +participants file contains a sorted host_wipe_participants list. Those +identities must later submit signed post-wipe evidence before release. `, "phase1": `Usage: mpc-ceremony phase1 [flags] @@ -458,11 +461,24 @@ 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 attest-host-wipe": `Usage: + mpc-ceremony ops attest-host-wipe --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --participant-id ID --participant-signing-key KEY \ + --wiped-at RFC3339 --out-dir DIR + +Run this only after the Mac used for a production contribution has undergone +a supported whole-device erase and clean macOS reinstall. Do not restore old +Docker Desktop data, snapshots, backups, or contribution copies. The signed +record is an authenticated honest-participant claim, not physical proof of +erasure. Release verification rejects a required record that does not postdate +the participant's final contribution. `, "ops prepare-public-witness-receipt": `Usage: mpc-ceremony ops prepare-public-witness-receipt \ diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index e88e3914..1aa80852 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -31,6 +31,14 @@ versions, compiler/build policies, dirty states, or multiple binaries for one platform. The signed definition records the full exact-digest allowlist; legacy v1 definitions remain one-binary ceremonies. +The same v2 definition may freeze a sorted production Mac wipe policy through +the participant input's `host_wipe_participants` field. The operational +evidence bundle schema v2 carries the corresponding signed host-wipe records. +Release signing recursively verifies that every required record belongs to the +rostered participant and postdates that participant's final contribution, so +accepted contributions can remain provisional without allowing premature +parameter release. + ## Coordinated distribution Compatibility with Relay is tested after both projects have released diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index f7d71720..362253c5 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -25,6 +25,14 @@ allowlist member, and every contribution attestation records the digest that actually ran. A v1 definition is intentionally interpreted as a singleton allowlist. +For a production ceremony that permits macOS participants through Docker, the +canonical participant input also freezes a sorted `host_wipe_participants` +list into the v2 signed definition. Their contributions may be accepted before +the whole Mac is erased, but final operational-evidence and release +verification require a participant-signed post-wipe record that is later than +that participant's final accepted contribution. This is authenticated +honest-participant evidence, not physical proof that no earlier copy exists. + ## Single-Actor Local Setup Run the local path with: @@ -98,3 +106,11 @@ The MPC path narrows the trust assumption to require at least one honest independent contributor in each phase, but it does not cryptographically prove that a contributor erased its randomness. Every accepted participant must use and attest to the host controls in the MPC runbook. + +For a participant named by the signed production Mac wipe policy, the immediate +container-erasure record permits contribution acceptance but is not the final +host-level gate. After the participant's last contribution, the whole Mac is +erased and cleanly reinstalled without restoring backups, snapshots, Docker +Desktop state, or contribution copies. The participant then runs `mpc-ceremony +ops attest-host-wipe` through Relay's guided flow. Release verification rejects +a missing, duplicate, invalid, or too-early required record. diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index e970dbfa..009f9fbd 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -3,6 +3,7 @@ package mpcceremony import ( "errors" "fmt" + "slices" ) const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 @@ -23,37 +24,39 @@ const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 const ProductionWitnessObservationWindowSeconds uint32 = 60 * 60 type CeremonyDefinition struct { - Schema string `json:"schema"` - CeremonyID string `json:"ceremony_id"` - Mode string `json:"mode"` - CreatedAt string `json:"created_at"` - SessionNonceHex string `json:"session_nonce_hex"` - Circuit CircuitBinding `json:"circuit"` - Software SoftwareBinding `json:"software"` - Coordinator Identity `json:"coordinator"` - ReleaseSigner Identity `json:"release_signer"` - Auditors []Identity `json:"auditors"` - Roster []Participant `json:"roster"` - Phase1Policy PhasePolicy `json:"phase1_policy"` - Phase2Policy PhasePolicy `json:"phase2_policy"` - BeaconPolicy BeaconPolicy `json:"beacon_policy"` - Phase1Genesis ArtifactRef `json:"phase1_genesis"` + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + CreatedAt string `json:"created_at"` + SessionNonceHex string `json:"session_nonce_hex"` + Circuit CircuitBinding `json:"circuit"` + Software SoftwareBinding `json:"software"` + Coordinator Identity `json:"coordinator"` + ReleaseSigner Identity `json:"release_signer"` + Auditors []Identity `json:"auditors"` + Roster []Participant `json:"roster"` + HostWipeParticipants []string `json:"host_wipe_participants,omitempty"` + Phase1Policy PhasePolicy `json:"phase1_policy"` + Phase2Policy PhasePolicy `json:"phase2_policy"` + BeaconPolicy BeaconPolicy `json:"beacon_policy"` + Phase1Genesis ArtifactRef `json:"phase1_genesis"` } type DefinitionOptions struct { - Mode string - CreatedAt string - SessionNonceHex string - Circuit CircuitBinding - Software SoftwareBinding - Coordinator Identity - ReleaseSigner Identity - Auditors []Identity - Roster []Participant - Phase1Policy PhasePolicy - Phase2Policy PhasePolicy - BeaconPolicy BeaconPolicy - Phase1Genesis ArtifactRef + Mode string + CreatedAt string + SessionNonceHex string + Circuit CircuitBinding + Software SoftwareBinding + Coordinator Identity + ReleaseSigner Identity + Auditors []Identity + Roster []Participant + HostWipeParticipants []string + Phase1Policy PhasePolicy + Phase2Policy PhasePolicy + BeaconPolicy BeaconPolicy + Phase1Genesis ArtifactRef } func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error) { @@ -62,20 +65,21 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error software.Binaries = []SoftwareBinary{software.primaryBinary()} } definition := CeremonyDefinition{ - Schema: DefinitionSchema, - Mode: options.Mode, - CreatedAt: options.CreatedAt, - SessionNonceHex: options.SessionNonceHex, - Circuit: options.Circuit, - Software: software, - Coordinator: options.Coordinator, - ReleaseSigner: options.ReleaseSigner, - Auditors: append([]Identity(nil), options.Auditors...), - Roster: append([]Participant(nil), options.Roster...), - Phase1Policy: clonePhasePolicy(options.Phase1Policy), - Phase2Policy: clonePhasePolicy(options.Phase2Policy), - BeaconPolicy: options.BeaconPolicy, - Phase1Genesis: options.Phase1Genesis, + Schema: DefinitionSchema, + Mode: options.Mode, + CreatedAt: options.CreatedAt, + SessionNonceHex: options.SessionNonceHex, + Circuit: options.Circuit, + Software: software, + Coordinator: options.Coordinator, + ReleaseSigner: options.ReleaseSigner, + Auditors: append([]Identity(nil), options.Auditors...), + Roster: append([]Participant(nil), options.Roster...), + HostWipeParticipants: append([]string(nil), options.HostWipeParticipants...), + Phase1Policy: clonePhasePolicy(options.Phase1Policy), + Phase2Policy: clonePhasePolicy(options.Phase2Policy), + BeaconPolicy: options.BeaconPolicy, + Phase1Genesis: options.Phase1Genesis, } id, err := ComputeCeremonyID(definition) if err != nil { @@ -138,8 +142,8 @@ func (d CeremonyDefinition) validate(requireID bool) error { switch d.Schema { case DefinitionSchema: case DefinitionSchemaV1: - if len(d.Software.Binaries) != 0 || d.Software.GoARM64 != "" { - return errors.New("definition v1 must not contain v2 software fields") + if len(d.Software.Binaries) != 0 || d.Software.GoARM64 != "" || len(d.HostWipeParticipants) != 0 { + return errors.New("definition v1 must not contain v2-only fields") } default: return fmt.Errorf( @@ -301,6 +305,26 @@ func (d CeremonyDefinition) validate(requireID bool) error { keyIDs[keyID] = "participant" publicKeyFingerprints[participant.Identity.PublicKeyFingerprint] = "participant" } + if len(d.HostWipeParticipants) > len(d.Roster) { + return errors.New("host_wipe_participants cannot exceed the signed roster") + } + if !slices.IsSorted(d.HostWipeParticipants) { + return errors.New("host_wipe_participants must be sorted") + } + for index, id := range d.HostWipeParticipants { + if index > 0 && id == d.HostWipeParticipants[index-1] { + return errors.New("host_wipe_participants must not contain duplicates") + } + if _, ok := roster[id]; !ok { + return fmt.Errorf("host-wipe participant %q is not in the signed roster", id) + } + if !slices.Contains(d.Phase1Policy.Participants, id) && !slices.Contains(d.Phase2Policy.Participants, id) { + return fmt.Errorf("host-wipe participant %q is not scheduled in either phase", id) + } + } + if d.Mode == ModeRehearsal && len(d.HostWipeParticipants) != 0 { + return errors.New("rehearsal ceremony must not require production host wipes") + } if err := d.Phase1Policy.Validate(roster); err != nil { return fmt.Errorf("phase1_policy: %w", err) } diff --git a/internal/mpcceremony/host_wipe.go b/internal/mpcceremony/host_wipe.go new file mode 100644 index 00000000..2f1e8b79 --- /dev/null +++ b/internal/mpcceremony/host_wipe.go @@ -0,0 +1,222 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "time" +) + +const ( + HostWipeAttestationSchema = "proof-tool-mpc-host-wipe-attestation-v1" + HostWipeRecordFile = "host-wipe.json" + HostWipeSignatureFile = "host-wipe.sig" +) + +// HostWipeAttestation is an authenticated participant claim made only after +// the Mac used for contribution has been erased. It is intentionally not +// described as physical proof: software cannot rule out a copy made before +// the wipe. Production release verification uses it as an honest-operator +// gate and checks that it postdates the participant's final contribution. +type HostWipeAttestation struct { + Schema string `json:"schema"` + HostWipeID string `json:"host_wipe_id"` + CeremonyID string `json:"ceremony_id"` + ParticipantID string `json:"participant_id"` + ParticipantKeyID string `json:"participant_key_id"` + HostOS string `json:"host_os"` + WholeDeviceErased bool `json:"whole_device_erased"` + OperatingSystemReinstalled bool `json:"operating_system_reinstalled"` + NoPreWipeSystemBackupOrSnapshotRestored bool `json:"no_pre_wipe_system_backup_or_snapshot_restored"` + NoDockerDesktopStateRestored bool `json:"no_docker_desktop_state_restored"` + NoContributionRandomnessCopyRetained bool `json:"no_contribution_randomness_copy_retained"` + WipedAt string `json:"wiped_at"` +} + +func NewHostWipeAttestation(value HostWipeAttestation) (HostWipeAttestation, error) { + value.Schema = HostWipeAttestationSchema + value.HostWipeID = "" + id, err := ComputeHostWipeAttestationID(value) + if err != nil { + return HostWipeAttestation{}, err + } + value.HostWipeID = id + return value, value.Validate() +} + +func ComputeHostWipeAttestationID(value HostWipeAttestation) (string, error) { + value.HostWipeID = "" + if err := value.validate(false); err != nil { + return "", err + } + return canonicalHash("proof-tool/mpc-ceremony/host-wipe-attestation/v1", value) +} + +func (a HostWipeAttestation) Validate() error { + if err := a.validate(true); err != nil { + return err + } + expected, err := ComputeHostWipeAttestationID(a) + if err != nil { + return err + } + if a.HostWipeID != expected { + return fmt.Errorf("host_wipe_id %q, want %q", a.HostWipeID, expected) + } + return nil +} + +func (a HostWipeAttestation) validate(requireID bool) error { + if a.Schema != HostWipeAttestationSchema { + return fmt.Errorf("host-wipe schema %q, want %q", a.Schema, HostWipeAttestationSchema) + } + if requireID { + if err := validateHashID("host_wipe_id", a.HostWipeID); err != nil { + return err + } + } else if a.HostWipeID != "" { + return errors.New("host_wipe_id must be empty while computing identity") + } + if err := validateHashID("ceremony_id", a.CeremonyID); err != nil { + return err + } + if err := validateID("participant_id", a.ParticipantID); err != nil { + return err + } + if err := validateID("participant_key_id", a.ParticipantKeyID); err != nil { + return err + } + if a.HostOS != "darwin" { + return fmt.Errorf("host_os %q, want darwin", a.HostOS) + } + if !a.WholeDeviceErased || !a.OperatingSystemReinstalled || + !a.NoPreWipeSystemBackupOrSnapshotRestored || !a.NoDockerDesktopStateRestored || + !a.NoContributionRandomnessCopyRetained { + return errors.New("all host-wipe assertions must be true") + } + return validateTimestamp("wiped_at", a.WipedAt) +} + +func VerifyHostWipeAttestation( + definition CeremonyDefinition, + recordBytes, signatureBytes []byte, +) (HostWipeAttestation, error) { + if err := definition.Validate(); err != nil { + return HostWipeAttestation{}, err + } + var record HostWipeAttestation + if err := UnmarshalCanonical(recordBytes, &record); err != nil { + return HostWipeAttestation{}, err + } + if record.CeremonyID != definition.CeremonyID || + !slices.Contains(definition.HostWipeParticipants, record.ParticipantID) { + return HostWipeAttestation{}, errors.New("host-wipe attestation is not required by this ceremony and participant") + } + participant, ok := definition.ParticipantByID(record.ParticipantID) + if !ok || participant.Identity.KeyID != record.ParticipantKeyID { + return HostWipeAttestation{}, errors.New("host-wipe participant identity does not match the signed roster") + } + publicKey, err := identityPublicKey(participant.Identity) + if err != nil { + return HostWipeAttestation{}, err + } + if err := VerifySignedRecord( + recordBytes, + signatureBytes, + &record, + participant.Identity.KeyID, + publicKey, + ); err != nil { + return HostWipeAttestation{}, err + } + if err := record.Validate(); err != nil { + return HostWipeAttestation{}, err + } + return record, nil +} + +type CreateHostWipeAttestationFilesOptions struct { + Trust TrustPaths + ParticipantID string + ParticipantPrivateKeyPath string + WipedAt string + OutDir string +} + +type CreateHostWipeAttestationFilesResult struct { + Attestation HostWipeAttestation + AttestationPath string + SignaturePath string +} + +func CreateHostWipeAttestationFiles( + options CreateHostWipeAttestationFilesOptions, +) (result CreateHostWipeAttestationFilesResult, err error) { + trusted, err := loadOperationalCeremony(options.Trust) + if err != nil { + return result, err + } + if trusted.Definition.Mode != ModeProduction { + return result, errors.New("host-wipe attestations apply only to production ceremonies") + } + if !slices.Contains(trusted.Definition.HostWipeParticipants, options.ParticipantID) { + return result, errors.New("participant is not required to provide a host-wipe attestation") + } + participant, ok := trusted.Definition.ParticipantByID(options.ParticipantID) + if !ok { + return result, errors.New("host-wipe participant is not in the signed roster") + } + privateKey, _, err := loadMatchingPrivateKey(options.ParticipantPrivateKeyPath, participant.Identity) + if err != nil { + return result, fmt.Errorf("participant signing key: %w", err) + } + wipedAt, err := time.Parse(time.RFC3339Nano, options.WipedAt) + if err != nil { + return result, errors.New("wiped_at must be RFC3339") + } + createdAt, _ := time.Parse(time.RFC3339Nano, trusted.Definition.CreatedAt) + if !wipedAt.After(createdAt) { + return result, errors.New("wiped_at must strictly postdate ceremony creation") + } + record, err := NewHostWipeAttestation(HostWipeAttestation{ + CeremonyID: trusted.Definition.CeremonyID, + ParticipantID: participant.Identity.ID, + ParticipantKeyID: participant.Identity.KeyID, + HostOS: "darwin", + WholeDeviceErased: true, + OperatingSystemReinstalled: true, + NoPreWipeSystemBackupOrSnapshotRestored: true, + NoDockerDesktopStateRestored: true, + NoContributionRandomnessCopyRetained: true, + WipedAt: wipedAt.UTC().Format(time.RFC3339Nano), + }) + if err != nil { + return result, err + } + if err := os.Mkdir(options.OutDir, 0o700); err != nil { + return result, fmt.Errorf("create fresh host-wipe output directory: %w", err) + } + created := true + defer func() { + if err != nil && created { + _ = os.RemoveAll(options.OutDir) + } + }() + result.AttestationPath = filepath.Join(options.OutDir, HostWipeRecordFile) + result.SignaturePath = filepath.Join(options.OutDir, HostWipeSignatureFile) + if err := writeSignedRecordNoReplace( + result.AttestationPath, + result.SignaturePath, + record, + participant.Identity.KeyID, + ed25519.PrivateKey(privateKey), + ); err != nil { + return result, err + } + result.Attestation = record + created = false + return result, nil +} diff --git a/internal/mpcceremony/host_wipe_test.go b/internal/mpcceremony/host_wipe_test.go new file mode 100644 index 00000000..9f5ec393 --- /dev/null +++ b/internal/mpcceremony/host_wipe_test.go @@ -0,0 +1,224 @@ +package mpcceremony + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestHostWipeAttestationAndReleaseGate(t *testing.T) { + definition := adversarialDefinition(t) + definition.HostWipeParticipants = []string{"participant-01"} + var err error + definition, err = FinalizeCeremonyDefinition(definition) + if err != nil { + t.Fatal(err) + } + record, err := NewHostWipeAttestation(HostWipeAttestation{ + CeremonyID: definition.CeremonyID, + ParticipantID: "participant-01", + ParticipantKeyID: definition.Roster[0].Identity.KeyID, + HostOS: "darwin", + WholeDeviceErased: true, + OperatingSystemReinstalled: true, + NoPreWipeSystemBackupOrSnapshotRestored: true, + NoDockerDesktopStateRestored: true, + NoContributionRandomnessCopyRetained: true, + WipedAt: "2026-07-23T15:00:00Z", + }) + if err != nil { + t.Fatal(err) + } + recordBytes, signatureBytes, err := SignRecord( + record, + definition.Roster[0].Identity.KeyID, + adversarialPrivateKey(0x11), + ) + if err != nil { + t.Fatal(err) + } + verified, err := VerifyHostWipeAttestation(definition, recordBytes, signatureBytes) + if err != nil || verified.HostWipeID != record.HostWipeID { + t.Fatalf("verify host wipe = %#v, %v", verified, err) + } + tamperedSignature := append([]byte(nil), signatureBytes...) + tamperedSignature[len(tamperedSignature)-1] ^= 1 + if _, err := VerifyHostWipeAttestation(definition, recordBytes, tamperedSignature); err == nil { + t.Fatal("tampered host-wipe signature accepted") + } + + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + write := func(name string, raw []byte) ArtifactRef { + t.Helper() + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + return ArtifactRef{Name: name, Digest: NewDigest(raw)} + } + contribution, err := NewContributionAttestation(ContributionAttestation{ + CeremonyID: definition.CeremonyID, Phase: Phase2, + PhaseID: "sha256:" + strings.Repeat("c", 64), Index: 1, + ParticipantID: "participant-01", ParticipantKeyID: definition.Roster[0].Identity.KeyID, + PreviousPayload: ArtifactRef{Name: "phase2/genesis.bin", Digest: NewDigest([]byte("before"))}, + OutputPayload: ArtifactRef{Name: "phase2/contribution.bin", Digest: NewDigest([]byte("after"))}, + PreviousAcceptanceID: "sha256:" + strings.Repeat("d", 64), + ToolBinary: definition.Software.ToolBinary, SourceCommit: definition.Software.SourceCommit, + GnarkVersion: GnarkVersion, GnarkCryptoVersion: GnarkCryptoVersion, DrandVersion: DrandVersion, + Environment: ContributionEnvironment{ + OS: "linux", Architecture: "arm64", EntropySource: "operating-system-csprng", + SwapDisabled: true, CrashDumpsDisabled: true, TelemetryDisabled: true, + EphemeralEnvironment: true, EphemeralDestructionRequired: true, + }, + ContributedAt: "2026-07-23T14:00:00Z", + }) + if err != nil { + t.Fatal(err) + } + attestationBytes, err := MarshalCanonical(contribution) + if err != nil { + t.Fatal(err) + } + attestation := write("phase2/contributions/0001/attestation.json", attestationBytes) + phaseID := "sha256:" + strings.Repeat("c", 64) + genesis := contribution.PreviousPayload + acceptedChain, err := NewChain(definition.CeremonyID, Phase2, phaseID, genesis) + if err != nil { + t.Fatal(err) + } + previousRecordID, err := GenesisRecordID(definition.CeremonyID, phaseID, genesis) + if err != nil { + t.Fatal(err) + } + chainRecord, err := NewChainRecord(ChainRecord{ + CeremonyID: definition.CeremonyID, + Phase: Phase2, + PhaseID: phaseID, + Index: 1, + ParticipantID: "participant-01", + PreviousPayload: contribution.PreviousPayload, + OutputPayload: contribution.OutputPayload, + AttestationID: contribution.AttestationID, + Attestation: attestation, + AttestationSignature: ArtifactRef{Name: "phase2/contributions/0001/attestation.sig", Digest: NewDigest([]byte("attestation signature"))}, + ErasureID: "sha256:" + strings.Repeat("e", 64), + Erasure: ArtifactRef{Name: "phase2/contributions/0001/erasure.json", Digest: NewDigest([]byte("erasure"))}, + ErasureSignature: ArtifactRef{Name: "phase2/contributions/0001/erasure.sig", Digest: NewDigest([]byte("erasure signature"))}, + Verification: ArtifactRef{Name: "phase2/contributions/0001/verification.json", Digest: NewDigest([]byte("verification"))}, + PreviousRecordID: previousRecordID, + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + AcceptedAt: "2026-07-23T14:30:00Z", + }) + if err != nil { + t.Fatal(err) + } + if err := acceptedChain.Append(chainRecord); err != nil { + t.Fatal(err) + } + chainBytes, err := MarshalCanonical(acceptedChain) + if err != nil { + t.Fatal(err) + } + chain := write("phase2/chain.json", chainBytes) + emptyGenesis := ArtifactRef{Name: "phase1/genesis.bin", Digest: NewDigest([]byte("phase1 genesis"))} + emptyChainModel, err := NewChain( + definition.CeremonyID, + Phase1, + "sha256:"+strings.Repeat("f", 64), + emptyGenesis, + ) + if err != nil { + t.Fatal(err) + } + emptyChainBytes, err := MarshalCanonical(emptyChainModel) + if err != nil { + t.Fatal(err) + } + emptyChain := write("phase1/chain.json", emptyChainBytes) + wipeRecord := write("host-wipes/participant-01.json", recordBytes) + wipeSignature := write("host-wipes/participant-01.sig", signatureBytes) + bundle := OperationalEvidenceBundle{ + HostWipes: []SignedArtifactRefs{{Record: wipeRecord, Signature: wipeSignature}}, + Phase1: PhaseOperationalEvidence{AcceptedChain: SignedArtifactRefs{Record: emptyChain}}, + Phase2: PhaseOperationalEvidence{AcceptedChain: SignedArtifactRefs{Record: chain}}, + } + if _, err := verifyHostWipeEvidence(definition, root, bundle); err != nil { + t.Fatalf("valid release host-wipe gate: %v", err) + } + + missing := bundle + missing.HostWipes = nil + if _, err := verifyHostWipeEvidence(definition, root, missing); err == nil || + !strings.Contains(err.Error(), "want exactly 1") { + t.Fatalf("missing host wipe error = %v", err) + } + + tooEarly := record + tooEarly.WipedAt = "2026-07-23T13:00:00Z" + tooEarly.HostWipeID = "" + tooEarly, err = NewHostWipeAttestation(tooEarly) + if err != nil { + t.Fatal(err) + } + earlyBytes, earlySignature, err := SignRecord( + tooEarly, + definition.Roster[0].Identity.KeyID, + adversarialPrivateKey(0x11), + ) + if err != nil { + t.Fatal(err) + } + bundle.HostWipes[0] = SignedArtifactRefs{ + Record: write("host-wipes/too-early.json", earlyBytes), + Signature: write("host-wipes/too-early.sig", earlySignature), + } + if _, err := verifyHostWipeEvidence(definition, root, bundle); err == nil || + !strings.Contains(err.Error(), "does not postdate") { + t.Fatalf("early host wipe error = %v", err) + } +} + +func TestHostWipeDefinitionPolicyIsFrozenAndOrdered(t *testing.T) { + definition := adversarialDefinition(t) + definition.HostWipeParticipants = []string{"participant-02", "participant-01"} + if _, err := FinalizeCeremonyDefinition(definition); err == nil || + !strings.Contains(err.Error(), "must be sorted") { + t.Fatalf("unsorted host-wipe policy error = %v", err) + } + + definition.HostWipeParticipants = []string{"participant-99"} + if _, err := FinalizeCeremonyDefinition(definition); err == nil || + !strings.Contains(err.Error(), "not in the signed roster") { + t.Fatalf("unknown host-wipe participant error = %v", err) + } + + definition = adversarialDefinition(t) + definition.Mode = ModeRehearsal + definition.HostWipeParticipants = []string{"participant-01"} + if _, err := FinalizeCeremonyDefinition(definition); err == nil || + !strings.Contains(err.Error(), "rehearsal ceremony") { + t.Fatalf("rehearsal host-wipe policy error = %v", err) + } +} + +func TestHostWipeTimestampParsesNanoseconds(t *testing.T) { + value := time.Date(2026, 7, 23, 15, 0, 0, 123, time.UTC).Format(time.RFC3339Nano) + if err := (HostWipeAttestation{ + Schema: HostWipeAttestationSchema, HostWipeID: "sha256:" + strings.Repeat("a", 64), + CeremonyID: "sha256:" + strings.Repeat("b", 64), ParticipantID: "participant-01", + ParticipantKeyID: "participant-key", HostOS: "darwin", WholeDeviceErased: true, + OperatingSystemReinstalled: true, NoPreWipeSystemBackupOrSnapshotRestored: true, + NoDockerDesktopStateRestored: true, NoContributionRandomnessCopyRetained: true, WipedAt: value, + }).validate(true); err != nil { + t.Fatal(err) + } +} diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index 666de88c..c43e2253 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -35,12 +35,13 @@ const ( RecordMirrorReceipt OperationalRecordType = "mirror-receipt" RecordEvidenceBundle OperationalRecordType = "evidence-bundle" RecordGovernance OperationalRecordType = "governance" + RecordHostWipe OperationalRecordType = "host-wipe" ) func (t OperationalRecordType) Validate() error { switch t { case RecordEnrollment, RecordHandoff, RecordReceipt, RecordPublicWitness, - RecordBeaconEvidence, RecordMirrorReceipt, RecordEvidenceBundle, RecordGovernance: + RecordBeaconEvidence, RecordMirrorReceipt, RecordEvidenceBundle, RecordGovernance, RecordHostWipe: return nil default: return fmt.Errorf("unsupported operational record type %q", t) @@ -643,6 +644,8 @@ func ParseOperationalRecord(recordType OperationalRecordType, canonical []byte) destination = &OperationalEvidenceBundle{} case RecordGovernance: destination = &GovernanceRecord{} + case RecordHostWipe: + destination = &HostWipeAttestation{} default: return nil, fmt.Errorf("unsupported operational record type %q", recordType) } @@ -745,6 +748,14 @@ func VerifyOperationalRecordBinding( ceremonyID, signerID, signerKeyID = r.CeremonyID, r.CoordinatorID, r.CoordinatorKeyID case *GovernanceRecord: ceremonyID, signerID, signerKeyID = r.CeremonyID, r.SignerID, r.SignerKeyID + case *HostWipeAttestation: + ceremonyID, signerID, signerKeyID = r.CeremonyID, r.ParticipantID, r.ParticipantKeyID + if err := r.Validate(); err != nil { + return Identity{}, err + } + if !slices.Contains(definition.HostWipeParticipants, r.ParticipantID) { + return Identity{}, errors.New("host-wipe participant is not required by the signed ceremony") + } default: return Identity{}, fmt.Errorf("unsupported operational record %T", record) } diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index 590ef729..bf4aec36 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -9,7 +9,10 @@ import ( "time" ) -const OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v1" +const ( + OperationalEvidenceBundleSchemaV1 = "proof-tool-mpc-operational-evidence-bundle-v1" + OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v2" +) type SignedArtifactRefs struct { Record ArtifactRef `json:"record"` @@ -138,6 +141,7 @@ type OperationalEvidenceBundle struct { CeremonyID string `json:"ceremony_id"` Enrollments []SignedArtifactRefs `json:"enrollments"` GovernanceRecords []SignedArtifactRefs `json:"governance_records"` + HostWipes []SignedArtifactRefs `json:"host_wipes,omitempty"` Phase1 PhaseOperationalEvidence `json:"phase1"` Phase2 PhaseOperationalEvidence `json:"phase2"` CoordinatorID string `json:"coordinator_id"` @@ -146,8 +150,8 @@ type OperationalEvidenceBundle struct { } func (b OperationalEvidenceBundle) Validate() error { - if b.Schema != OperationalEvidenceBundleSchema { - return fmt.Errorf("operational evidence schema %q, want %q", b.Schema, OperationalEvidenceBundleSchema) + if b.Schema != OperationalEvidenceBundleSchemaV1 && b.Schema != OperationalEvidenceBundleSchema { + return fmt.Errorf("operational evidence schema %q is unsupported", b.Schema) } if err := validateHashID("ceremony_id", b.CeremonyID); err != nil { return err @@ -167,6 +171,17 @@ func (b OperationalEvidenceBundle) Validate() error { return err } } + if b.Schema == OperationalEvidenceBundleSchemaV1 && len(b.HostWipes) != 0 { + return errors.New("operational evidence v1 must not contain host wipes") + } + if len(b.HostWipes) > MaxParticipants { + return fmt.Errorf("host_wipes exceeds maximum %d", MaxParticipants) + } + if len(b.HostWipes) > 0 { + if err := validateSignedArtifactSet("host_wipes", b.HostWipes); err != nil { + return err + } + } if err := b.Phase1.Validate(); err != nil { return fmt.Errorf("phase1: %w", err) } @@ -239,7 +254,8 @@ type VerifiedOperationalEvidence struct { // VerifyOperationalEvidenceBundle fail-closes across the signed bundle, // authenticated close records, witness signatures/quorum/timing, every raw -// relay response, and the pinned drand verification policy. +// relay response, the pinned drand verification policy, and every post-wipe +// Mac attestation required by the signed ceremony definition. func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) (VerifiedOperationalEvidence, error) { if err := options.Definition.Validate(); err != nil { return VerifiedOperationalEvidence{}, err @@ -310,6 +326,10 @@ func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) ( if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err) } + hostWipeRefs, err := verifyHostWipeEvidence(options.Definition, options.EvidenceRoot, bundle) + if err != nil { + return VerifiedOperationalEvidence{}, fmt.Errorf("host-wipe evidence: %w", err) + } latest, err := latestOperationalTimestamp(options.EvidenceRoot, bundle) if err != nil { return VerifiedOperationalEvidence{}, err @@ -325,6 +345,7 @@ func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) ( all := append(enrollmentRefs, governanceRefs...) all = append(all, phase1Refs...) all = append(all, phase2Refs...) + all = append(all, hostWipeRefs...) slices.SortFunc(all, func(a, b ArtifactRef) int { if a.Name < b.Name { return -1 @@ -385,6 +406,17 @@ func latestOperationalTimestamp(root string, bundle OperationalEvidenceBundle) ( } advance(record.RecordedAt) } + for _, pair := range bundle.HostWipes { + raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) + if err != nil { + return time.Time{}, err + } + var record HostWipeAttestation + if err := UnmarshalCanonical(raw, &record); err != nil { + return time.Time{}, err + } + advance(record.WipedAt) + } for _, phase := range []PhaseOperationalEvidence{bundle.Phase1, bundle.Phase2} { chainBytes, err := verifyArtifactBytes(root, phase.AcceptedChain.Record, maxSignedRecordBytes) if err != nil { @@ -471,6 +503,83 @@ func latestOperationalTimestamp(root string, bundle OperationalEvidenceBundle) ( return latest, nil } +func verifyHostWipeEvidence( + definition CeremonyDefinition, + root string, + bundle OperationalEvidenceBundle, +) ([]ArtifactRef, error) { + required := definition.HostWipeParticipants + if len(bundle.HostWipes) != len(required) { + return nil, fmt.Errorf("got %d host-wipe attestations, want exactly %d", len(bundle.HostWipes), len(required)) + } + if len(required) == 0 { + return nil, nil + } + latestContribution := make(map[string]time.Time, len(required)) + for _, phase := range []PhaseOperationalEvidence{bundle.Phase1, bundle.Phase2} { + chainBytes, err := verifyArtifactBytes(root, phase.AcceptedChain.Record, maxSignedRecordBytes) + if err != nil { + return nil, err + } + var chain Chain + if err := UnmarshalCanonical(chainBytes, &chain); err != nil { + return nil, err + } + for _, record := range chain.Records { + if !slices.Contains(required, record.ParticipantID) { + continue + } + attestationBytes, err := verifyArtifactBytes(root, record.Attestation, maxSignedRecordBytes) + if err != nil { + return nil, err + } + var attestation ContributionAttestation + if err := UnmarshalCanonical(attestationBytes, &attestation); err != nil { + return nil, err + } + contributed, _ := time.Parse(time.RFC3339Nano, attestation.ContributedAt) + if contributed.After(latestContribution[record.ParticipantID]) { + latestContribution[record.ParticipantID] = contributed + } + } + } + seen := make(map[string]struct{}, len(required)) + refs := make([]ArtifactRef, 0, len(bundle.HostWipes)*2) + for index, pair := range bundle.HostWipes { + recordBytes, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) + if err != nil { + return nil, fmt.Errorf("host wipe %d record: %w", index, err) + } + signatureBytes, err := verifyArtifactBytes(root, pair.Signature, maxSignedRecordBytes) + if err != nil { + return nil, fmt.Errorf("host wipe %d signature: %w", index, err) + } + record, err := VerifyHostWipeAttestation(definition, recordBytes, signatureBytes) + if err != nil { + return nil, fmt.Errorf("host wipe %d: %w", index, err) + } + if _, duplicate := seen[record.ParticipantID]; duplicate { + return nil, fmt.Errorf("host wipe for participant %q is duplicated", record.ParticipantID) + } + contributed, ok := latestContribution[record.ParticipantID] + if !ok { + return nil, fmt.Errorf("host-wipe participant %q has no accepted contribution", record.ParticipantID) + } + wiped, _ := time.Parse(time.RFC3339Nano, record.WipedAt) + if !wiped.After(contributed) { + return nil, fmt.Errorf("host wipe for participant %q does not postdate their final contribution", record.ParticipantID) + } + seen[record.ParticipantID] = struct{}{} + refs = append(refs, pair.Record, pair.Signature) + } + for _, participantID := range required { + if _, ok := seen[participantID]; !ok { + return nil, fmt.Errorf("required host wipe for participant %q is missing", participantID) + } + } + return refs, nil +} + func verifyPhaseOperationalEvidence( definition CeremonyDefinition, coordinatorPublicKey ed25519.PublicKey, diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 65014506..46ccec41 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -43,6 +43,15 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Fatalf("complete operational bundle rejected: %v", err) } + t.Run("legacy v1 bundle without host-wipe policy", func(t *testing.T) { + f := newOperationalBundleFixture(t) + f.bundle.Schema = OperationalEvidenceBundleSchemaV1 + resignBundle(t, &f) + if err := verify(f); err != nil { + t.Fatalf("legacy operational bundle rejected: %v", err) + } + }) + t.Run("missing enrollment", func(t *testing.T) { f := newOperationalBundleFixture(t) f.bundle.Enrollments = f.bundle.Enrollments[1:] diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index d686f3a4..aa654a59 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -60,10 +60,11 @@ type TrustedCeremony struct { // InitParticipants is the fixed-field, canonical enrollment input accepted by // the coordinator init command. It contains public signing identities only. type InitParticipants struct { - Coordinator Identity `json:"coordinator"` - ReleaseSigner Identity `json:"release_signer"` - Auditors []Identity `json:"auditors"` - Roster []Participant `json:"roster"` + Coordinator Identity `json:"coordinator"` + ReleaseSigner Identity `json:"release_signer"` + Auditors []Identity `json:"auditors"` + Roster []Participant `json:"roster"` + HostWipeParticipants []string `json:"host_wipe_participants,omitempty"` } func (p InitParticipants) Validate() error { @@ -86,6 +87,7 @@ func (p InitParticipants) Validate() error { identityIDs := make(map[string]string, 2+len(p.Auditors)+len(p.Roster)) keyIDs := make(map[string]string, 2+len(p.Auditors)+len(p.Roster)) publicKeyFingerprints := make(map[string]string, 2+len(p.Auditors)+len(p.Roster)) + rosterIDs := make(map[string]struct{}, len(p.Roster)) add := func(identity Identity, role string) error { if previous, exists := identityIDs[identity.ID]; exists { return fmt.Errorf("%s identity %q duplicates %s", role, identity.ID, previous) @@ -122,6 +124,18 @@ func (p InitParticipants) Validate() error { if err := add(participant.Identity, "participant"); err != nil { return err } + rosterIDs[participant.Identity.ID] = struct{}{} + } + if !slices.IsSorted(p.HostWipeParticipants) { + return errors.New("host_wipe_participants must be sorted") + } + for index, id := range p.HostWipeParticipants { + if index > 0 && id == p.HostWipeParticipants[index-1] { + return errors.New("host_wipe_participants must not contain duplicates") + } + if _, ok := rosterIDs[id]; !ok { + return fmt.Errorf("host-wipe participant %q is not in the roster", id) + } } return nil } From 99a6195a8c0809b75aa4756529b635b3fa7577dc Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 4 Sep 2026 19:39:56 +0900 Subject: [PATCH 45/64] fix: satisfy host wipe lint --- internal/mpcceremony/host_wipe.go | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/internal/mpcceremony/host_wipe.go b/internal/mpcceremony/host_wipe.go index 2f1e8b79..0bc9080c 100644 --- a/internal/mpcceremony/host_wipe.go +++ b/internal/mpcceremony/host_wipe.go @@ -1,7 +1,6 @@ package mpcceremony import ( - "crypto/ed25519" "errors" "fmt" "os" @@ -212,7 +211,7 @@ func CreateHostWipeAttestationFiles( result.SignaturePath, record, participant.Identity.KeyID, - ed25519.PrivateKey(privateKey), + privateKey, ); err != nil { return result, err } From a9ac255502f0614a9095c52c545aa9f28e8f12cc Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Mon, 7 Sep 2026 18:56:36 +0900 Subject: [PATCH 46/64] ci: build attested signed-tag MPC candidates --- .../publish-mpc-ceremony-candidate.yml | 104 ++++++++++++++++++ docs/mpc-ceremony-release.md | 16 +++ release/keys/relay-tag-signing-public.asc | 11 ++ scripts/build-mpc-ceremony-release.sh | 17 ++- scripts/verify-mpc-build-metadata/main.go | 18 +-- scripts/verify-mpc-ceremony-reproducible.sh | 22 ++-- 6 files changed, 170 insertions(+), 18 deletions(-) create mode 100644 .github/workflows/publish-mpc-ceremony-candidate.yml create mode 100644 release/keys/relay-tag-signing-public.asc diff --git a/.github/workflows/publish-mpc-ceremony-candidate.yml b/.github/workflows/publish-mpc-ceremony-candidate.yml new file mode 100644 index 00000000..6a77abb7 --- /dev/null +++ b/.github/workflows/publish-mpc-ceremony-candidate.yml @@ -0,0 +1,104 @@ +name: Publish MPC ceremony candidate + +on: + push: + tags: ["mpc-v*"] + workflow_dispatch: + inputs: + source_tag: + description: Signed mpc-v* tag to build + required: true + type: string + +# This workflow publishes an attested *candidate* only. The offline build +# signing key is intentionally unavailable to GitHub Actions, so a successful +# run can never create a production package. +permissions: + contents: read + attestations: write + id-token: write + +concurrency: + group: mpc-ceremony-candidate-${{ inputs.source_tag || github.ref_name }} + cancel-in-progress: false + +jobs: + build: + name: Reproducible signed-tag candidate + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + SOURCE_TAG: ${{ inputs.source_tag || github.ref_name }} + TAG_SIGNER_FINGERPRINT: D27746F50177107AF060C35B69375FD4AA575389 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + ref: ${{ env.SOURCE_TAG }} + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + - name: Authenticate the source tag + shell: bash + run: | + set -euo pipefail + 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 + export GNUPGHOME="$RUNNER_TEMP/gnupg" + mkdir -m 700 "$GNUPGHOME" + gpg --batch --import release/keys/relay-tag-signing-public.asc + verified=$(git verify-tag --raw "$SOURCE_TAG" 2>&1) + printf '%s\n' "$verified" + printf '%s\n' "$verified" | grep -q "^\[GNUPG:\] VALIDSIG $TAG_SIGNER_FINGERPRINT " + test "$(git rev-parse "$SOURCE_TAG^{commit}")" = "$(git rev-parse HEAD)" + + - name: Bootstrap patched vendor tree + run: bash scripts/bootstrap-vendor.sh + + - name: Build two signed-tag candidates + shell: bash + run: | + set -euo pipefail + mkdir "$RUNNER_TEMP/mpc-candidate-a-parent" + mkdir "$RUNNER_TEMP/mpc-candidate-b-parent" + export GNUPGHOME="$RUNNER_TEMP/gnupg" + scripts/build-mpc-ceremony-release.sh \ + --mode candidate \ + --signed-tag "$SOURCE_TAG" \ + --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ + --out-dir "$RUNNER_TEMP/mpc-candidate-a-parent/release" + scripts/build-mpc-ceremony-release.sh \ + --mode candidate \ + --signed-tag "$SOURCE_TAG" \ + --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ + --out-dir "$RUNNER_TEMP/mpc-candidate-b-parent/release" + + - name: Verify byte-for-byte reproducibility + shell: bash + run: | + export GNUPGHOME="$RUNNER_TEMP/gnupg" + scripts/verify-mpc-ceremony-reproducible.sh \ + --mode candidate \ + --expected-commit "$(git rev-parse HEAD)" \ + --expected-tag "$SOURCE_TAG" \ + --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ + --trusted-build-public-key-file none \ + "$RUNNER_TEMP/mpc-candidate-a-parent/release" \ + "$RUNNER_TEMP/mpc-candidate-b-parent/release" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mpc-ceremony-candidate-${{ env.SOURCE_TAG }} + path: ${{ runner.temp }}/mpc-candidate-a-parent/release + if-no-files-found: error + + - name: Attest the candidate package + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: ${{ runner.temp }}/mpc-candidate-a-parent/release diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index 1aa80852..f119697e 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -23,6 +23,22 @@ offline build-signing key. Publish both `mpc-ceremony` (Linux/amd64) and `mpc-ceremony-linux-arm64`, together with their complete verification package, through proof-tool's release process. +## CI candidate and offline approval + +Pushing an `mpc-v*` tag runs `Publish MPC ceremony candidate`. It imports the +checked-in public release-tag key, requires the tag to verify to the approved +fingerprint, builds the AMD64 and ARM64 package twice, compares the two +packages byte-for-byte, and uploads one package with GitHub build provenance. +The artifact has `build-mode.txt = candidate`: it is explicitly **not** a +production release and Relay must not consume it. + +The workflow never receives the offline Ed25519 build-signing key. A release +maintainer independently rebuilds the same signed tag with `--mode production` +on the approved offline Linux/AMD64 environment, verifies the candidate and +the independent build, then publishes the signed production package and its +SHA-256 values. This means a compromised CI credential can create an observable +candidate, but cannot replace the production ceremony runtime. + New ceremony definitions use schema v2. The coordinator runs either released binary and passes the other with repeated `--allowed-binary FILE` flags during `init` (or `rehearsal init`). Initialization reads the embedded Go build diff --git a/release/keys/relay-tag-signing-public.asc b/release/keys/relay-tag-signing-public.asc new file mode 100644 index 00000000..3583fccf --- /dev/null +++ b/release/keys/relay-tag-signing-public.asc @@ -0,0 +1,11 @@ +-----BEGIN PGP PUBLIC KEY BLOCK----- + +mDMEap5K/BYJKwYBBAHaRw8BAQdA1KStZm9im7aSDJLkGgm+rEoXVOSEggj6NV0Z +SkD5ZCS0W2phc29uIChSZWxheSBhbmQgcHJvb2YtdG9vbCByZWxlYXNlIHNpZ25p +bmcpIDw5NDYxODUyNCttZWxsb3djcm9jQHVzZXJzLm5vcmVwbHkuZ2l0aHViLmNv +bT6ItQQTFgoAXRYhBNJ3RvUBdxB68GDDW2k3X9SqV1OJBQJqnkr8GxSAAAAAAAQA +Dm1hbnUyLDIuNSsxLjEyLDAsMwIbAwUJA8JnAAULCQgHAgIiAgYVCgkICwIEFgID +AQIeBwIXgAAKCRBpN1/UqldTiQqhAP49Z015GnPutRj6V4d4O4LDJFbV/oEK86TQ +oH/B7PoRDQEAt7ihT8GyHxmcxwOGHKEoVAmF+YcHBfWcMMlw+eRi3QU= +=vCAo +-----END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/build-mpc-ceremony-release.sh b/scripts/build-mpc-ceremony-release.sh index cbdbf737..0e9ede80 100755 --- a/scripts/build-mpc-ceremony-release.sh +++ b/scripts/build-mpc-ceremony-release.sh @@ -10,6 +10,14 @@ # --build-signing-key /offline/build-signing-key \ # --out-dir /fresh/output # +# A CI candidate is bound to a verified source tag but deliberately has no +# offline build-package signature. It is evidence for the offline releaser, +# never a production release: +# scripts/build-mpc-ceremony-release.sh \ +# --mode candidate --signed-tag vX.Y.Z \ +# --tag-signer-fingerprint "$APPROVED_GPG_FINGERPRINT" \ +# --out-dir /fresh/output +# # Rehearsals deliberately record that no signed-tag gate was applied: # scripts/build-mpc-ceremony-release.sh \ # --mode rehearsal --out-dir /fresh/output @@ -25,7 +33,7 @@ export GIT_CONFIG_GLOBAL=/dev/null export GIT_CONFIG_NOSYSTEM=1 usage() { - echo "usage: $0 --mode production|rehearsal --out-dir DIR [--signed-tag TAG --tag-signer-fingerprint HEX] [--build-signing-key KEY]" >&2 + echo "usage: $0 --mode production|candidate|rehearsal --out-dir DIR [--signed-tag TAG --tag-signer-fingerprint HEX] [--build-signing-key KEY]" >&2 exit 2 } @@ -67,7 +75,7 @@ while [[ $# -gt 0 ]]; do esac done -if [[ "$MODE" != "production" && "$MODE" != "rehearsal" ]]; then +if [[ "$MODE" != "production" && "$MODE" != "candidate" && "$MODE" != "rehearsal" ]]; then usage fi if [[ -z "$OUT_DIR" ]]; then @@ -78,6 +86,11 @@ if [[ "$MODE" == "production" && echo "FAIL: production builds require --signed-tag, --tag-signer-fingerprint, and --build-signing-key" >&2 exit 1 fi +if [[ "$MODE" == "candidate" && + ( -z "$SIGNED_TAG" || -z "$TAG_SIGNER_FINGERPRINT" || -n "$BUILD_SIGNING_KEY" ) ]]; then + echo "FAIL: candidates require --signed-tag and --tag-signer-fingerprint, and must not use a build-signing key" >&2 + exit 1 +fi if [[ "$MODE" == "rehearsal" && ( -n "$SIGNED_TAG" || -n "$TAG_SIGNER_FINGERPRINT" || -n "$BUILD_SIGNING_KEY" ) ]]; then echo "FAIL: rehearsal builds must not supply production tag or build-signing identity" >&2 diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index 8cf911a9..f569cd79 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -132,19 +132,19 @@ func main() { dir := flag.String("dir", "", "build package directory") mode := flag.String("mode", "", "expected build mode") commit := flag.String("commit", "", "expected lowercase 40-character source commit") - tag := flag.String("tag", "", "expected signed production tag or none") + tag := flag.String("tag", "", "expected signed tag for production/candidate, or none") fingerprint := flag.String("tag-signer-fingerprint", "", "expected uppercase tag signer fingerprint or none") sourceRoot := flag.String("source-root", "", "exact clean source checkout used to independently verify source and SBOM identities") trustedBuildPublicKey := flag.String("trusted-build-public-key-file", "", "out-of-band trusted Ed25519 build public key or none") flag.Parse() - if flag.NArg() != 0 || *dir == "" || (*mode != "production" && *mode != "rehearsal") || + if flag.NArg() != 0 || *dir == "" || (*mode != "production" && *mode != "candidate" && *mode != "rehearsal") || !lowerCommitPattern.MatchString(*commit) || *tag == "" || *fingerprint == "" || *sourceRoot == "" || *trustedBuildPublicKey == "" { - fatal(errors.New("usage: verify-mpc-build-metadata --dir DIR --mode production|rehearsal --commit COMMIT --tag TAG|none --tag-signer-fingerprint HEX|none --source-root DIR --trusted-build-public-key-file FILE|none")) + fatal(errors.New("usage: verify-mpc-build-metadata --dir DIR --mode production|candidate|rehearsal --commit COMMIT --tag TAG|none --tag-signer-fingerprint HEX|none --source-root DIR --trusted-build-public-key-file FILE|none")) } if (*mode == "production" && *trustedBuildPublicKey == "none") || - (*mode == "rehearsal" && *trustedBuildPublicKey != "none") { - fatal(errors.New("production requires an out-of-band trusted build public key; rehearsal requires none")) + ((*mode == "candidate" || *mode == "rehearsal") && *trustedBuildPublicKey != "none") { + fatal(errors.New("production requires an out-of-band trusted build public key; candidates and rehearsals require none")) } if err := verifyPlainIdentity(*dir, *mode, *commit, *tag, *fingerprint); err != nil { fatal(err) @@ -252,10 +252,10 @@ func verifyPlainIdentity(dir, mode, commit, tag, fingerprint string) error { if err != nil { return err } - if mode == "production" { + if mode == "production" || mode == "candidate" { if tag == "none" || !fingerprintPattern.MatchString(fingerprint) || status != "verified" || !lowerCommitPattern.MatchString(tagObject) { - return errors.New("production package does not contain an exact verified signed-tag identity") + return fmt.Errorf("%s package does not contain an exact verified signed-tag identity", mode) } } else if tag != "none" || fingerprint != "none" || status != "not-required-for-rehearsal" || tagObject != "none" { @@ -525,10 +525,10 @@ func verifyRootManifest(dir string, manifest digestManifest) error { func verifyBuildSignature(dir, mode, trustedPublicKeyPath string) error { signaturePath := filepath.Join(dir, "build-package-manifest.sig") bundledKeyPath := filepath.Join(dir, "build-package-manifest-public-key.hex") - if mode == "rehearsal" { + if mode == "rehearsal" || mode == "candidate" { for _, path := range []string{signaturePath, bundledKeyPath} { if _, err := os.Lstat(path); err == nil { - return fmt.Errorf("rehearsal package unexpectedly contains %s", filepath.Base(path)) + return fmt.Errorf("%s package unexpectedly contains %s", mode, filepath.Base(path)) } else if !errors.Is(err, fs.ErrNotExist) { return err } diff --git a/scripts/verify-mpc-ceremony-reproducible.sh b/scripts/verify-mpc-ceremony-reproducible.sh index 1d0b3a22..6ee9f092 100755 --- a/scripts/verify-mpc-ceremony-reproducible.sh +++ b/scripts/verify-mpc-ceremony-reproducible.sh @@ -11,7 +11,7 @@ export GIT_CONFIG_GLOBAL=/dev/null export GIT_CONFIG_NOSYSTEM=1 usage() { - echo "usage: $0 --mode production|rehearsal --expected-commit COMMIT --expected-tag TAG|none --tag-signer-fingerprint HEX|none --trusted-build-public-key-file FILE|none BUILD_DIR_A BUILD_DIR_B" >&2 + echo "usage: $0 --mode production|candidate|rehearsal --expected-commit COMMIT --expected-tag TAG|none --tag-signer-fingerprint HEX|none --trusted-build-public-key-file FILE|none BUILD_DIR_A BUILD_DIR_B" >&2 exit 2 } @@ -59,7 +59,7 @@ while [[ $# -gt 0 ]]; do ;; esac done -if [[ $# -ne 2 || ( "$MODE" != "production" && "$MODE" != "rehearsal" ) || +if [[ $# -ne 2 || ( "$MODE" != "production" && "$MODE" != "candidate" && "$MODE" != "rehearsal" ) || ! "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ || -z "$EXPECTED_TAG" || -z "$TAG_SIGNER_FINGERPRINT" || -z "$TRUSTED_BUILD_PUBLIC_KEY_FILE" ]]; then @@ -77,6 +77,12 @@ if [[ "$MODE" == "production" ]]; then echo "FAIL: trusted build public key must be a non-symlink regular file" >&2 exit 1 fi +elif [[ "$MODE" == "candidate" ]]; then + if [[ "$EXPECTED_TAG" == "none" || + ! "$TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-F]{40}|[0-9A-F]{64})$ || + "$TRUSTED_BUILD_PUBLIC_KEY_FILE" != "none" ]]; then + usage + fi elif [[ "$EXPECTED_TAG" != "none" || "$TAG_SIGNER_FINGERPRINT" != "NONE" || "$TRUSTED_BUILD_PUBLIC_KEY_FILE" != "none" ]]; then usage @@ -124,22 +130,22 @@ mapfile -t EXPECTED_FILES < <(printf '%s\n' "${EXPECTED_FILES[@]}" | LC_ALL=C so SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) REPO_ROOT=$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel) -if [[ "$MODE" == "production" ]]; then +if [[ "$MODE" == "production" || "$MODE" == "candidate" ]]; then if [[ "$EXPECTED_TAG" == -* ]] || ! git -C "$REPO_ROOT" check-ref-format "refs/tags/$EXPECTED_TAG"; then - echo "FAIL: invalid expected production tag: $EXPECTED_TAG" >&2 + echo "FAIL: invalid expected signed tag: $EXPECTED_TAG" >&2 exit 1 fi VERIFIED_TAG_COMMIT=$(git -C "$REPO_ROOT" rev-parse --verify "$EXPECTED_TAG^{commit}") if [[ "$VERIFIED_TAG_COMMIT" != "$EXPECTED_COMMIT" ]]; then - echo "FAIL: expected production tag does not resolve to the expected commit" >&2 + echo "FAIL: expected signed tag does not resolve to the expected commit" >&2 exit 1 fi VERIFIED_TAG_OBJECT=$(git -C "$REPO_ROOT" rev-parse --verify "$EXPECTED_TAG^{tag}") VERIFY_TAG_OUTPUT= if ! VERIFY_TAG_OUTPUT=$(git -C "$REPO_ROOT" verify-tag --raw "$EXPECTED_TAG" 2>&1); then printf '%s\n' "$VERIFY_TAG_OUTPUT" >&2 - echo "FAIL: independent production tag verification failed" >&2 + echo "FAIL: independent signed tag verification failed" >&2 exit 1 fi mapfile -t VERIFIED_TAG_FINGERPRINTS < <( @@ -209,7 +215,7 @@ for dir in "$BUILD_A" "$BUILD_B"; do echo "FAIL: all release binaries must be executable: $dir" >&2 exit 1 fi - if [[ "$MODE" == "production" ]]; then + if [[ "$MODE" == "production" || "$MODE" == "candidate" ]]; then RECORDED_TAG_OBJECT=$(<"$dir/signed-tag-object.txt") if [[ "$RECORDED_TAG_OBJECT" != "$VERIFIED_TAG_OBJECT" ]]; then echo "FAIL: recorded signed tag object does not equal independently verified tag object: $dir" >&2 @@ -306,6 +312,8 @@ cmp "$BUILD_A/mpc-finalization-evidence" "$BUILD_B/mpc-finalization-evidence" if [[ "$MODE" == "production" ]]; then echo "OK: independent signed-tag production MPC ceremony release builds are semantically valid and byte-identical" +elif [[ "$MODE" == "candidate" ]]; then + echo "OK: independent signed-tag MPC ceremony candidates are semantically valid and byte-identical (NOT PRODUCTION)" else echo "OK: independent MPC ceremony rehearsal builds are semantically valid and byte-identical (NOT PRODUCTION)" fi From de15e8a4f8b0a856d3fa77ef17187324b0ea5d51 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:44:34 +0900 Subject: [PATCH 47/64] ci: release MPC packages from protected main --- .../publish-mpc-ceremony-candidate.yml | 104 -------------- .../publish-mpc-ceremony-release.yml | 90 ++++++++++++ docs/mpc-ceremony-release.md | 36 ++--- release/keys/relay-tag-signing-public.asc | 11 -- scripts/build-mpc-ceremony-release.sh | 135 ++---------------- scripts/verify-mpc-build-metadata/main.go | 29 ++-- scripts/verify-mpc-ceremony-reproducible.sh | 71 +-------- 7 files changed, 134 insertions(+), 342 deletions(-) delete mode 100644 .github/workflows/publish-mpc-ceremony-candidate.yml create mode 100644 .github/workflows/publish-mpc-ceremony-release.yml delete mode 100644 release/keys/relay-tag-signing-public.asc diff --git a/.github/workflows/publish-mpc-ceremony-candidate.yml b/.github/workflows/publish-mpc-ceremony-candidate.yml deleted file mode 100644 index 6a77abb7..00000000 --- a/.github/workflows/publish-mpc-ceremony-candidate.yml +++ /dev/null @@ -1,104 +0,0 @@ -name: Publish MPC ceremony candidate - -on: - push: - tags: ["mpc-v*"] - workflow_dispatch: - inputs: - source_tag: - description: Signed mpc-v* tag to build - required: true - type: string - -# This workflow publishes an attested *candidate* only. The offline build -# signing key is intentionally unavailable to GitHub Actions, so a successful -# run can never create a production package. -permissions: - contents: read - attestations: write - id-token: write - -concurrency: - group: mpc-ceremony-candidate-${{ inputs.source_tag || github.ref_name }} - cancel-in-progress: false - -jobs: - build: - name: Reproducible signed-tag candidate - runs-on: ubuntu-latest - timeout-minutes: 45 - env: - SOURCE_TAG: ${{ inputs.source_tag || github.ref_name }} - TAG_SIGNER_FINGERPRINT: D27746F50177107AF060C35B69375FD4AA575389 - steps: - - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 - with: - ref: ${{ env.SOURCE_TAG }} - fetch-depth: 0 - persist-credentials: false - - - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 - with: - go-version-file: go.mod - cache: false - - - name: Authenticate the source tag - shell: bash - run: | - set -euo pipefail - 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 - export GNUPGHOME="$RUNNER_TEMP/gnupg" - mkdir -m 700 "$GNUPGHOME" - gpg --batch --import release/keys/relay-tag-signing-public.asc - verified=$(git verify-tag --raw "$SOURCE_TAG" 2>&1) - printf '%s\n' "$verified" - printf '%s\n' "$verified" | grep -q "^\[GNUPG:\] VALIDSIG $TAG_SIGNER_FINGERPRINT " - test "$(git rev-parse "$SOURCE_TAG^{commit}")" = "$(git rev-parse HEAD)" - - - name: Bootstrap patched vendor tree - run: bash scripts/bootstrap-vendor.sh - - - name: Build two signed-tag candidates - shell: bash - run: | - set -euo pipefail - mkdir "$RUNNER_TEMP/mpc-candidate-a-parent" - mkdir "$RUNNER_TEMP/mpc-candidate-b-parent" - export GNUPGHOME="$RUNNER_TEMP/gnupg" - scripts/build-mpc-ceremony-release.sh \ - --mode candidate \ - --signed-tag "$SOURCE_TAG" \ - --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ - --out-dir "$RUNNER_TEMP/mpc-candidate-a-parent/release" - scripts/build-mpc-ceremony-release.sh \ - --mode candidate \ - --signed-tag "$SOURCE_TAG" \ - --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ - --out-dir "$RUNNER_TEMP/mpc-candidate-b-parent/release" - - - name: Verify byte-for-byte reproducibility - shell: bash - run: | - export GNUPGHOME="$RUNNER_TEMP/gnupg" - scripts/verify-mpc-ceremony-reproducible.sh \ - --mode candidate \ - --expected-commit "$(git rev-parse HEAD)" \ - --expected-tag "$SOURCE_TAG" \ - --tag-signer-fingerprint "$TAG_SIGNER_FINGERPRINT" \ - --trusted-build-public-key-file none \ - "$RUNNER_TEMP/mpc-candidate-a-parent/release" \ - "$RUNNER_TEMP/mpc-candidate-b-parent/release" - - - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 - with: - name: mpc-ceremony-candidate-${{ env.SOURCE_TAG }} - path: ${{ runner.temp }}/mpc-candidate-a-parent/release - if-no-files-found: error - - - name: Attest the candidate package - uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 - with: - subject-path: ${{ runner.temp }}/mpc-candidate-a-parent/release diff --git a/.github/workflows/publish-mpc-ceremony-release.yml b/.github/workflows/publish-mpc-ceremony-release.yml new file mode 100644 index 00000000..5377e626 --- /dev/null +++ b/.github/workflows/publish-mpc-ceremony-release.yml @@ -0,0 +1,90 @@ +name: Publish MPC ceremony release + +on: + push: + branches: [main] + +# Protected-main review is the release gate. CI publishes only immutable, +# provenance-attested packages bound to github.sha; no GPG tag or offline key +# participates in this release model. +permissions: + contents: write + attestations: write + id-token: write + +concurrency: + group: mpc-ceremony-release-${{ github.sha }} + cancel-in-progress: false + +jobs: + build: + name: Reproducible protected-main release + runs-on: ubuntu-latest + timeout-minutes: 45 + env: + RELEASE_TAG: mpc-ci-${{ github.sha }} + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 1 + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + - name: Verify protected-main release inputs + shell: bash + run: | + set -euo pipefail + test "$GITHUB_REF" = refs/heads/main + 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 + + - name: Bootstrap patched vendor tree + run: bash scripts/bootstrap-vendor.sh + + - name: Build two CI releases + shell: bash + run: | + mkdir "$RUNNER_TEMP/mpc-release-a-parent" + mkdir "$RUNNER_TEMP/mpc-release-b-parent" + scripts/build-mpc-ceremony-release.sh --mode ci --out-dir "$RUNNER_TEMP/mpc-release-a-parent/release" + scripts/build-mpc-ceremony-release.sh --mode ci --out-dir "$RUNNER_TEMP/mpc-release-b-parent/release" + + - name: Verify byte-for-byte reproducibility + shell: bash + run: | + scripts/verify-mpc-ceremony-reproducible.sh \ + --mode ci --expected-commit "$GITHUB_SHA" --expected-tag none \ + --tag-signer-fingerprint none --trusted-build-public-key-file none \ + "$RUNNER_TEMP/mpc-release-a-parent/release" \ + "$RUNNER_TEMP/mpc-release-b-parent/release" + + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 + with: + name: mpc-ceremony-release-${{ github.sha }} + path: ${{ runner.temp }}/mpc-release-a-parent/release + if-no-files-found: error + retention-days: 90 + + - name: Attest release package + uses: actions/attest-build-provenance@96278af6caaf10aea03fd8d33a09a777ca52d62f # v3.2.0 + with: + subject-path: ${{ runner.temp }}/mpc-release-a-parent/release + + - name: Write release notes + shell: bash + run: | + printf 'Protected-main CI release for commit `%s`. Verify GitHub build provenance before use.\n' "$GITHUB_SHA" > "$RUNNER_TEMP/release-notes.md" + + - uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 + with: + tag_name: ${{ env.RELEASE_TAG }} + target_commitish: ${{ github.sha }} + name: MPC ceremony ${{ env.RELEASE_TAG }} + body_path: ${{ runner.temp }}/release-notes.md + files: ${{ runner.temp }}/mpc-release-a-parent/release/* diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index f119697e..cf73de3b 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -16,28 +16,20 @@ following proof-tool properties: 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 both `mpc-ceremony` (Linux/amd64) and -`mpc-ceremony-linux-arm64`, together with their complete verification package, -through proof-tool's release process. - -## CI candidate and offline approval - -Pushing an `mpc-v*` tag runs `Publish MPC ceremony candidate`. It imports the -checked-in public release-tag key, requires the tag to verify to the approved -fingerprint, builds the AMD64 and ARM64 package twice, compares the two -packages byte-for-byte, and uploads one package with GitHub build provenance. -The artifact has `build-mode.txt = candidate`: it is explicitly **not** a -production release and Relay must not consume it. - -The workflow never receives the offline Ed25519 build-signing key. A release -maintainer independently rebuilds the same signed tag with `--mode production` -on the approved offline Linux/AMD64 environment, verifies the candidate and -the independent build, then publishes the signed production package and its -SHA-256 values. This means a compromised CI credential can create an observable -candidate, but cannot replace the production ceremony runtime. +## Protected-main CI releases + +The protected `main` branch is the release gate. Each merge to `main` runs +`Publish MPC ceremony release`, builds the Linux/amd64 and Linux/arm64 package +twice, compares them byte-for-byte, publishes the complete package as a GitHub +Release, and attaches GitHub build provenance. The generated distribution tag +(`mpc-ci-`) is a delivery label, not a source-approval signature. + +Coordinators verify that provenance against the expected repository, workflow, +and exact `main` commit before using a package. There is no GPG source-tag +signer, offline build-signing key, or manual release-signer step in this model. +Protect `main` with required review, required CI checks, CODEOWNERS review for +release workflows, no direct pushes, and no force pushes. GitHub Actions is +therefore part of the trusted release boundary. New ceremony definitions use schema v2. The coordinator runs either released binary and passes the other with repeated `--allowed-binary FILE` flags during diff --git a/release/keys/relay-tag-signing-public.asc b/release/keys/relay-tag-signing-public.asc deleted file mode 100644 index 3583fccf..00000000 --- a/release/keys/relay-tag-signing-public.asc +++ /dev/null @@ -1,11 +0,0 @@ ------BEGIN PGP PUBLIC KEY BLOCK----- - -mDMEap5K/BYJKwYBBAHaRw8BAQdA1KStZm9im7aSDJLkGgm+rEoXVOSEggj6NV0Z -SkD5ZCS0W2phc29uIChSZWxheSBhbmQgcHJvb2YtdG9vbCByZWxlYXNlIHNpZ25p -bmcpIDw5NDYxODUyNCttZWxsb3djcm9jQHVzZXJzLm5vcmVwbHkuZ2l0aHViLmNv -bT6ItQQTFgoAXRYhBNJ3RvUBdxB68GDDW2k3X9SqV1OJBQJqnkr8GxSAAAAAAAQA -Dm1hbnUyLDIuNSsxLjEyLDAsMwIbAwUJA8JnAAULCQgHAgIiAgYVCgkICwIEFgID -AQIeBwIXgAAKCRBpN1/UqldTiQqhAP49Z015GnPutRj6V4d4O4LDJFbV/oEK86TQ -oH/B7PoRDQEAt7ihT8GyHxmcxwOGHKEoVAmF+YcHBfWcMMlw+eRi3QU= -=vCAo ------END PGP PUBLIC KEY BLOCK----- diff --git a/scripts/build-mpc-ceremony-release.sh b/scripts/build-mpc-ceremony-release.sh index 0e9ede80..bd5b7b32 100755 --- a/scripts/build-mpc-ceremony-release.sh +++ b/scripts/build-mpc-ceremony-release.sh @@ -2,20 +2,11 @@ # Builds the participant-facing MPC ceremony binary from an exact clean Git # state and records the inputs needed for independent byte-for-byte rebuilds. # -# Production usage requires a verified signed tag: +# CI releases are built only from a clean protected-main commit. GitHub Actions +# provenance, rather than a GPG tag or offline package signature, is the +# release identity: # scripts/build-mpc-ceremony-release.sh \ -# --mode production \ -# --signed-tag vX.Y.Z \ -# --tag-signer-fingerprint "$APPROVED_GPG_FINGERPRINT" \ -# --build-signing-key /offline/build-signing-key \ -# --out-dir /fresh/output -# -# A CI candidate is bound to a verified source tag but deliberately has no -# offline build-package signature. It is evidence for the offline releaser, -# never a production release: -# scripts/build-mpc-ceremony-release.sh \ -# --mode candidate --signed-tag vX.Y.Z \ -# --tag-signer-fingerprint "$APPROVED_GPG_FINGERPRINT" \ +# --mode ci \ # --out-dir /fresh/output # # Rehearsals deliberately record that no signed-tag gate was applied: @@ -33,15 +24,12 @@ export GIT_CONFIG_GLOBAL=/dev/null export GIT_CONFIG_NOSYSTEM=1 usage() { - echo "usage: $0 --mode production|candidate|rehearsal --out-dir DIR [--signed-tag TAG --tag-signer-fingerprint HEX] [--build-signing-key KEY]" >&2 + echo "usage: $0 --mode ci|rehearsal --out-dir DIR" >&2 exit 2 } MODE= OUT_DIR= -SIGNED_TAG= -TAG_SIGNER_FINGERPRINT= -BUILD_SIGNING_KEY= while [[ $# -gt 0 ]]; do case "$1" in --mode) @@ -54,69 +42,18 @@ while [[ $# -gt 0 ]]; do OUT_DIR=$2 shift 2 ;; - --signed-tag) - [[ $# -ge 2 ]] || usage - SIGNED_TAG=$2 - shift 2 - ;; - --tag-signer-fingerprint) - [[ $# -ge 2 ]] || usage - TAG_SIGNER_FINGERPRINT=$2 - shift 2 - ;; - --build-signing-key) - [[ $# -ge 2 ]] || usage - BUILD_SIGNING_KEY=$2 - shift 2 - ;; *) usage ;; esac done -if [[ "$MODE" != "production" && "$MODE" != "candidate" && "$MODE" != "rehearsal" ]]; then +if [[ "$MODE" != "ci" && "$MODE" != "rehearsal" ]]; then usage fi if [[ -z "$OUT_DIR" ]]; then usage fi -if [[ "$MODE" == "production" && - ( -z "$SIGNED_TAG" || -z "$TAG_SIGNER_FINGERPRINT" || -z "$BUILD_SIGNING_KEY" ) ]]; then - echo "FAIL: production builds require --signed-tag, --tag-signer-fingerprint, and --build-signing-key" >&2 - exit 1 -fi -if [[ "$MODE" == "candidate" && - ( -z "$SIGNED_TAG" || -z "$TAG_SIGNER_FINGERPRINT" || -n "$BUILD_SIGNING_KEY" ) ]]; then - echo "FAIL: candidates require --signed-tag and --tag-signer-fingerprint, and must not use a build-signing key" >&2 - exit 1 -fi -if [[ "$MODE" == "rehearsal" && - ( -n "$SIGNED_TAG" || -n "$TAG_SIGNER_FINGERPRINT" || -n "$BUILD_SIGNING_KEY" ) ]]; then - echo "FAIL: rehearsal builds must not supply production tag or build-signing identity" >&2 - exit 1 -fi -if [[ -n "$SIGNED_TAG" && -z "$TAG_SIGNER_FINGERPRINT" ]] || - [[ -z "$SIGNED_TAG" && -n "$TAG_SIGNER_FINGERPRINT" ]]; then - echo "FAIL: --signed-tag and --tag-signer-fingerprint must be supplied together" >&2 - exit 1 -fi -if [[ -n "$TAG_SIGNER_FINGERPRINT" ]]; then - TAG_SIGNER_FINGERPRINT=${TAG_SIGNER_FINGERPRINT^^} - if [[ ! "$TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-F]{40}|[0-9A-F]{64})$ ]]; then - echo "FAIL: tag signer fingerprint must be exactly 40 or 64 hexadecimal characters" >&2 - exit 1 - fi -fi - -if [[ -n "$BUILD_SIGNING_KEY" ]]; then - BUILD_SIGNING_KEY_DIR=$(realpath -e -- "$(dirname -- "$BUILD_SIGNING_KEY")") - BUILD_SIGNING_KEY="$BUILD_SIGNING_KEY_DIR/$(basename -- "$BUILD_SIGNING_KEY")" - if [[ ! -f "$BUILD_SIGNING_KEY" || -L "$BUILD_SIGNING_KEY" ]]; then - echo "FAIL: build signing key must be a non-symlink regular file" >&2 - exit 1 - fi -fi OUT_PARENT=$(realpath -e -- "$(dirname -- "$OUT_DIR")") OUT_DIR="$OUT_PARENT/$(basename -- "$OUT_DIR")" @@ -137,35 +74,10 @@ if [[ ! "$SOURCE_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then exit 1 fi -TAG_STATUS=not-required-for-rehearsal +TAG_STATUS=not-used-ci-attested TAG_OBJECT=none -if [[ -n "$SIGNED_TAG" ]]; then - if [[ "$SIGNED_TAG" == -* ]] || ! git check-ref-format "refs/tags/$SIGNED_TAG"; then - echo "FAIL: invalid signed tag name: $SIGNED_TAG" >&2 - exit 1 - fi - TAG_COMMIT=$(git rev-parse --verify "$SIGNED_TAG^{commit}") - if [[ "$TAG_COMMIT" != "$SOURCE_COMMIT" ]]; then - echo "FAIL: signed tag $SIGNED_TAG resolves to $TAG_COMMIT, not HEAD $SOURCE_COMMIT" >&2 - exit 1 - fi - TAG_OBJECT=$(git rev-parse --verify "$SIGNED_TAG^{tag}") - VERIFY_TAG_OUTPUT= - if ! VERIFY_TAG_OUTPUT=$(git verify-tag --raw "$SIGNED_TAG" 2>&1); then - printf '%s\n' "$VERIFY_TAG_OUTPUT" >&2 - echo "FAIL: signed tag verification failed: $SIGNED_TAG" >&2 - exit 1 - fi - mapfile -t VALID_TAG_FINGERPRINTS < <( - printf '%s\n' "$VERIFY_TAG_OUTPUT" | - sed -n 's/^\[GNUPG:\] VALIDSIG \([0-9A-Fa-f]*\) .*/\U\1/p' - ) - if [[ "${#VALID_TAG_FINGERPRINTS[@]}" -ne 1 || - "${VALID_TAG_FINGERPRINTS[0]}" != "$TAG_SIGNER_FINGERPRINT" ]]; then - echo "FAIL: signed tag fingerprint does not match the approved fingerprint" >&2 - exit 1 - fi - TAG_STATUS=verified +if [[ "$MODE" == "rehearsal" ]]; then + TAG_STATUS=not-required-for-rehearsal fi ACTIVE_GOROOT=$(env -u GOROOT \ @@ -514,10 +426,10 @@ env \ printf '%s\n' "$SOURCE_COMMIT" >"$STAGING/source-commit.txt" printf '%s\n' "$SOURCE_DATE_EPOCH" >"$STAGING/source-date-epoch.txt" printf '%s\n' "$MODE" >"$STAGING/build-mode.txt" -printf '%s\n' "${SIGNED_TAG:-none}" >"$STAGING/signed-tag.txt" +printf '%s\n' none >"$STAGING/signed-tag.txt" printf '%s\n' "$TAG_STATUS" >"$STAGING/signed-tag-status.txt" printf '%s\n' "$TAG_OBJECT" >"$STAGING/signed-tag-object.txt" -printf '%s\n' "${TAG_SIGNER_FINGERPRINT:-none}" >"$STAGING/signed-tag-signer-fingerprint.txt" +printf '%s\n' none >"$STAGING/signed-tag-signer-fingerprint.txt" cat >"$STAGING/toolchain-checksums.sha256" <build-package-manifest.sha256 ) -if [[ -n "$BUILD_SIGNING_KEY" ]]; then - env \ - -u GOROOT \ - CGO_ENABLED=0 \ - GOCACHE="$CANONICAL_ROOT/go-cache" \ - GOENV=off \ - GOEXPERIMENT= \ - GOFIPS140=off \ - GOTOOLCHAIN=local \ - GOWORK=off \ - GOOS=linux \ - GOARCH=amd64 \ - GOAMD64=v1 \ - GOFLAGS=-mod=vendor \ - "$GO_BIN" run ./scripts/sign-ed25519-file \ - --input "$STAGING/build-package-manifest.json" \ - --private-key "$BUILD_SIGNING_KEY" \ - --signature-out "$STAGING/build-package-manifest.sig" \ - --public-key-out "$STAGING/build-package-manifest-public-key.hex" -fi chmod 0555 \ "$STAGING/mpc-ceremony" \ @@ -603,11 +495,6 @@ chmod 0444 \ "$STAGING"/build-package-manifest.sha256 \ "$STAGING"/checksums.* \ "$STAGING"/*-checksums.sha256 -if [[ -n "$BUILD_SIGNING_KEY" ]]; then - chmod 0444 \ - "$STAGING"/build-package-manifest.sig \ - "$STAGING"/build-package-manifest-public-key.hex -fi touch -d "@$SOURCE_DATE_EPOCH" "$STAGING"/* env \ -u GOROOT \ diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index f569cd79..991356bc 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -132,19 +132,18 @@ func main() { dir := flag.String("dir", "", "build package directory") mode := flag.String("mode", "", "expected build mode") commit := flag.String("commit", "", "expected lowercase 40-character source commit") - tag := flag.String("tag", "", "expected signed tag for production/candidate, or none") - fingerprint := flag.String("tag-signer-fingerprint", "", "expected uppercase tag signer fingerprint or none") + tag := flag.String("tag", "", "legacy expected tag or none; CI releases use none") + fingerprint := flag.String("tag-signer-fingerprint", "", "legacy tag fingerprint or none; CI releases use none") sourceRoot := flag.String("source-root", "", "exact clean source checkout used to independently verify source and SBOM identities") trustedBuildPublicKey := flag.String("trusted-build-public-key-file", "", "out-of-band trusted Ed25519 build public key or none") flag.Parse() - if flag.NArg() != 0 || *dir == "" || (*mode != "production" && *mode != "candidate" && *mode != "rehearsal") || + if flag.NArg() != 0 || *dir == "" || (*mode != "ci" && *mode != "rehearsal") || !lowerCommitPattern.MatchString(*commit) || *tag == "" || *fingerprint == "" || *sourceRoot == "" || *trustedBuildPublicKey == "" { - fatal(errors.New("usage: verify-mpc-build-metadata --dir DIR --mode production|candidate|rehearsal --commit COMMIT --tag TAG|none --tag-signer-fingerprint HEX|none --source-root DIR --trusted-build-public-key-file FILE|none")) + fatal(errors.New("usage: verify-mpc-build-metadata --dir DIR --mode ci|rehearsal --commit COMMIT --tag none --tag-signer-fingerprint none --source-root DIR --trusted-build-public-key-file none")) } - if (*mode == "production" && *trustedBuildPublicKey == "none") || - ((*mode == "candidate" || *mode == "rehearsal") && *trustedBuildPublicKey != "none") { - fatal(errors.New("production requires an out-of-band trusted build public key; candidates and rehearsals require none")) + if *trustedBuildPublicKey != "none" { + fatal(errors.New("CI and rehearsal packages require no out-of-band build signing key")) } if err := verifyPlainIdentity(*dir, *mode, *commit, *tag, *fingerprint); err != nil { fatal(err) @@ -252,14 +251,14 @@ func verifyPlainIdentity(dir, mode, commit, tag, fingerprint string) error { if err != nil { return err } - if mode == "production" || mode == "candidate" { - if tag == "none" || !fingerprintPattern.MatchString(fingerprint) || - status != "verified" || !lowerCommitPattern.MatchString(tagObject) { - return fmt.Errorf("%s package does not contain an exact verified signed-tag identity", mode) + if mode == "ci" { + if tag != "none" || fingerprint != "none" || status != "not-used-ci-attested" || tagObject != "none" { + return errors.New("CI package contains inconsistent release identity") + } + } else { + if tag != "none" || fingerprint != "none" || status != "not-required-for-rehearsal" || tagObject != "none" { + return errors.New("untagged rehearsal package contains inconsistent signed-tag identity") } - } else if tag != "none" || fingerprint != "none" || - status != "not-required-for-rehearsal" || tagObject != "none" { - return errors.New("untagged rehearsal package contains inconsistent signed-tag identity") } epoch, err := readOneLine(filepath.Join(dir, "source-date-epoch.txt")) if err != nil { @@ -525,7 +524,7 @@ func verifyRootManifest(dir string, manifest digestManifest) error { func verifyBuildSignature(dir, mode, trustedPublicKeyPath string) error { signaturePath := filepath.Join(dir, "build-package-manifest.sig") bundledKeyPath := filepath.Join(dir, "build-package-manifest-public-key.hex") - if mode == "rehearsal" || mode == "candidate" { + if mode == "rehearsal" || mode == "ci" { for _, path := range []string{signaturePath, bundledKeyPath} { if _, err := os.Lstat(path); err == nil { return fmt.Errorf("%s package unexpectedly contains %s", mode, filepath.Base(path)) diff --git a/scripts/verify-mpc-ceremony-reproducible.sh b/scripts/verify-mpc-ceremony-reproducible.sh index 6ee9f092..c0e09869 100755 --- a/scripts/verify-mpc-ceremony-reproducible.sh +++ b/scripts/verify-mpc-ceremony-reproducible.sh @@ -11,7 +11,7 @@ export GIT_CONFIG_GLOBAL=/dev/null export GIT_CONFIG_NOSYSTEM=1 usage() { - echo "usage: $0 --mode production|candidate|rehearsal --expected-commit COMMIT --expected-tag TAG|none --tag-signer-fingerprint HEX|none --trusted-build-public-key-file FILE|none BUILD_DIR_A BUILD_DIR_B" >&2 + echo "usage: $0 --mode ci|rehearsal --expected-commit COMMIT --expected-tag none --tag-signer-fingerprint none --trusted-build-public-key-file none BUILD_DIR_A BUILD_DIR_B" >&2 exit 2 } @@ -59,31 +59,13 @@ while [[ $# -gt 0 ]]; do ;; esac done -if [[ $# -ne 2 || ( "$MODE" != "production" && "$MODE" != "candidate" && "$MODE" != "rehearsal" ) || +if [[ $# -ne 2 || ( "$MODE" != "ci" && "$MODE" != "rehearsal" ) || ! "$EXPECTED_COMMIT" =~ ^[0-9a-f]{40}$ || -z "$EXPECTED_TAG" || -z "$TAG_SIGNER_FINGERPRINT" || -z "$TRUSTED_BUILD_PUBLIC_KEY_FILE" ]]; then usage fi -if [[ "$MODE" == "production" ]]; then - if [[ "$EXPECTED_TAG" == "none" || - ! "$TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-F]{40}|[0-9A-F]{64})$ || - "$TRUSTED_BUILD_PUBLIC_KEY_FILE" == "none" ]]; then - usage - fi - TRUSTED_BUILD_PUBLIC_KEY_DIR=$(realpath -e -- "$(dirname -- "$TRUSTED_BUILD_PUBLIC_KEY_FILE")") - TRUSTED_BUILD_PUBLIC_KEY_FILE="$TRUSTED_BUILD_PUBLIC_KEY_DIR/$(basename -- "$TRUSTED_BUILD_PUBLIC_KEY_FILE")" - if [[ ! -f "$TRUSTED_BUILD_PUBLIC_KEY_FILE" || -L "$TRUSTED_BUILD_PUBLIC_KEY_FILE" ]]; then - echo "FAIL: trusted build public key must be a non-symlink regular file" >&2 - exit 1 - fi -elif [[ "$MODE" == "candidate" ]]; then - if [[ "$EXPECTED_TAG" == "none" || - ! "$TAG_SIGNER_FINGERPRINT" =~ ^([0-9A-F]{40}|[0-9A-F]{64})$ || - "$TRUSTED_BUILD_PUBLIC_KEY_FILE" != "none" ]]; then - usage - fi -elif [[ "$EXPECTED_TAG" != "none" || "$TAG_SIGNER_FINGERPRINT" != "NONE" || +if [[ "$EXPECTED_TAG" != "none" || "$TAG_SIGNER_FINGERPRINT" != "NONE" || "$TRUSTED_BUILD_PUBLIC_KEY_FILE" != "none" ]]; then usage else @@ -120,44 +102,10 @@ EXPECTED_FILES=( toolchain-checksums.sha256 vendor-checksums.sha256 ) -if [[ "$MODE" == "production" ]]; then - EXPECTED_FILES+=( - build-package-manifest-public-key.hex - build-package-manifest.sig - ) -fi mapfile -t EXPECTED_FILES < <(printf '%s\n' "${EXPECTED_FILES[@]}" | LC_ALL=C sort) SCRIPT_DIR=$(cd "$(dirname "$0")" && pwd) REPO_ROOT=$(git -C "$SCRIPT_DIR/.." rev-parse --show-toplevel) -if [[ "$MODE" == "production" || "$MODE" == "candidate" ]]; then - if [[ "$EXPECTED_TAG" == -* ]] || - ! git -C "$REPO_ROOT" check-ref-format "refs/tags/$EXPECTED_TAG"; then - echo "FAIL: invalid expected signed tag: $EXPECTED_TAG" >&2 - exit 1 - fi - VERIFIED_TAG_COMMIT=$(git -C "$REPO_ROOT" rev-parse --verify "$EXPECTED_TAG^{commit}") - if [[ "$VERIFIED_TAG_COMMIT" != "$EXPECTED_COMMIT" ]]; then - echo "FAIL: expected signed tag does not resolve to the expected commit" >&2 - exit 1 - fi - VERIFIED_TAG_OBJECT=$(git -C "$REPO_ROOT" rev-parse --verify "$EXPECTED_TAG^{tag}") - VERIFY_TAG_OUTPUT= - if ! VERIFY_TAG_OUTPUT=$(git -C "$REPO_ROOT" verify-tag --raw "$EXPECTED_TAG" 2>&1); then - printf '%s\n' "$VERIFY_TAG_OUTPUT" >&2 - echo "FAIL: independent signed tag verification failed" >&2 - exit 1 - fi - mapfile -t VERIFIED_TAG_FINGERPRINTS < <( - printf '%s\n' "$VERIFY_TAG_OUTPUT" | - sed -n 's/^\[GNUPG:\] VALIDSIG \([0-9A-Fa-f]*\) .*/\U\1/p' - ) - if [[ "${#VERIFIED_TAG_FINGERPRINTS[@]}" -ne 1 || - "${VERIFIED_TAG_FINGERPRINTS[0]}" != "$TAG_SIGNER_FINGERPRINT" ]]; then - echo "FAIL: independent tag verification did not use the approved signer fingerprint" >&2 - exit 1 - fi -fi ACTIVE_GOROOT=$(env -u GOROOT \ CGO_ENABLED=0 \ GOARCH=amd64 \ @@ -215,13 +163,6 @@ for dir in "$BUILD_A" "$BUILD_B"; do echo "FAIL: all release binaries must be executable: $dir" >&2 exit 1 fi - if [[ "$MODE" == "production" || "$MODE" == "candidate" ]]; then - RECORDED_TAG_OBJECT=$(<"$dir/signed-tag-object.txt") - if [[ "$RECORDED_TAG_OBJECT" != "$VERIFIED_TAG_OBJECT" ]]; then - echo "FAIL: recorded signed tag object does not equal independently verified tag object: $dir" >&2 - exit 1 - fi - fi env \ -u GOROOT \ CGO_ENABLED=0 \ @@ -310,10 +251,8 @@ cmp "$BUILD_A/mpc-ceremony" "$BUILD_B/mpc-ceremony" cmp "$BUILD_A/mpc-ceremony-linux-arm64" "$BUILD_B/mpc-ceremony-linux-arm64" cmp "$BUILD_A/mpc-finalization-evidence" "$BUILD_B/mpc-finalization-evidence" -if [[ "$MODE" == "production" ]]; then - echo "OK: independent signed-tag production MPC ceremony release builds are semantically valid and byte-identical" -elif [[ "$MODE" == "candidate" ]]; then - echo "OK: independent signed-tag MPC ceremony candidates are semantically valid and byte-identical (NOT PRODUCTION)" +if [[ "$MODE" == "ci" ]]; then + echo "OK: protected-main CI MPC ceremony builds are semantically valid and byte-identical" else echo "OK: independent MPC ceremony rehearsal builds are semantically valid and byte-identical (NOT PRODUCTION)" fi From 53b2c2cd3270124e22a84752747debf4bf123cd1 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Mon, 7 Sep 2026 19:47:29 +0900 Subject: [PATCH 48/64] lint: remove obsolete tag fingerprint validator --- scripts/verify-mpc-build-metadata/main.go | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index 991356bc..4f9f109f 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -38,7 +38,6 @@ const ( var ( lowerCommitPattern = regexp.MustCompile(`^[0-9a-f]{40}$`) - fingerprintPattern = regexp.MustCompile(`^([0-9A-F]{40}|[0-9A-F]{64})$`) lowerSHA256Pattern = regexp.MustCompile(`^[0-9a-f]{64}$`) rootFileNames = []string{ "arm64-binary-manifest.json", From e7dac8e4e9c408672fd7514ade87deea695ea49c Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Tue, 8 Sep 2026 00:02:18 +0900 Subject: [PATCH 49/64] feat: replace host wipe gate with scoped cleanup claims (#18) --- cmd/mpc-ceremony/cli_test.go | 14 -- cmd/mpc-ceremony/executor.go | 27 +-- cmd/mpc-ceremony/inspect.go | 5 +- cmd/mpc-ceremony/inspect_test.go | 3 +- cmd/mpc-ceremony/integration_test.go | 4 - cmd/mpc-ceremony/main.go | 3 +- cmd/mpc-ceremony/ops.go | 27 --- cmd/mpc-ceremony/parse.go | 30 +-- cmd/mpc-ceremony/types.go | 24 +- cmd/mpc-ceremony/usage.go | 30 +-- docs/mpc-ceremony-release.md | 14 +- docs/trusted-setup-ceremony.md | 36 +-- internal/mpcceremony/adversarial_test.go | 61 ++--- internal/mpcceremony/attestation.go | 65 ++--- internal/mpcceremony/attestation_test.go | 66 ++++-- internal/mpcceremony/cleanup_policy_test.go | 41 ++++ internal/mpcceremony/definition.go | 110 ++++----- internal/mpcceremony/host_wipe.go | 221 ----------------- internal/mpcceremony/host_wipe_test.go | 224 ------------------ internal/mpcceremony/model.go | 4 +- internal/mpcceremony/operational.go | 13 +- internal/mpcceremony/operational_bundle.go | 114 +-------- .../mpcceremony/operational_bundle_test.go | 51 ++-- .../testdata/workflowhelper/main.go | 17 +- internal/mpcceremony/workflow.go | 47 ++-- internal/mpcceremony/workflow_test.go | 17 +- internal/mpcrehearsal/config.go | 17 +- 27 files changed, 322 insertions(+), 963 deletions(-) create mode 100644 internal/mpcceremony/cleanup_policy_test.go delete mode 100644 internal/mpcceremony/host_wipe.go delete mode 100644 internal/mpcceremony/host_wipe_test.go diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 1fafa11b..29174de7 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -345,20 +345,6 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { ), command: CommandDecisionVerify, }, - { - name: "ops attest host wipe", - args: joinArgs( - []string{"ops", "attest-host-wipe"}, - ceremonyTrust, - []string{ - "--participant-id", "participant-01", - "--participant-signing-key", "private/participant-01.key", - "--wiped-at", "2026-09-04T12:00:00Z", - "--out-dir", "ops/host-wipe", - }, - ), - command: CommandOpsAttestHostWipe, - }, { name: "ops prepare public witness receipt", args: joinArgs( diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 77b0ab69..4f54e8d6 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -73,8 +73,6 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeReleaseVerify(invocation.Options.(ReleaseVerifyOptions)) case CommandOpsPreparePublicWitnessReceipt: return executeOpsPreparePublicWitnessReceipt(invocation.Options.(OpsPreparePublicWitnessReceiptOptions)) - case CommandOpsAttestHostWipe: - return executeOpsAttestHostWipe(invocation.Options.(HostWipeOptions)) case CommandOpsPrepareMirrorReceipt: return executeOpsPrepareMirrorReceipt(invocation.Options.(OpsPrepareMirrorReceiptOptions)) case CommandOpsExportSigning: @@ -151,18 +149,17 @@ func executeInit(options InitOptions) (CommandResult, error) { RootDir: options.OutDir, Circuit: circuit, Definition: mpcceremony.DefinitionOptions{ - Mode: options.Mode, - CreatedAt: options.CreatedAt, - SessionNonceHex: nonce, - Software: runningSoftware, - Coordinator: participants.Coordinator, - ReleaseSigner: participants.ReleaseSigner, - Auditors: participants.Auditors, - Roster: participants.Roster, - HostWipeParticipants: participants.HostWipeParticipants, - Phase1Policy: policy.Phase1Policy, - Phase2Policy: policy.Phase2Policy, - BeaconPolicy: policy.BeaconPolicy, + Mode: options.Mode, + CreatedAt: options.CreatedAt, + SessionNonceHex: nonce, + Software: runningSoftware, + Coordinator: participants.Coordinator, + ReleaseSigner: participants.ReleaseSigner, + Auditors: participants.Auditors, + Roster: participants.Roster, + Phase1Policy: policy.Phase1Policy, + Phase2Policy: policy.Phase2Policy, + BeaconPolicy: policy.BeaconPolicy, }, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, }) @@ -305,7 +302,7 @@ func executeErasure(phase mpcceremony.Phase, options ErasureOptions) (CommandRes CeremonyID: result.Erasure.CeremonyID, Phase: string(phase), Sequence: int(result.Erasure.Index), - Summary: fmt.Sprintf("signed participant %s erasure attestation (not proof of erasure)", phase), + Summary: fmt.Sprintf("signed participant %s cleanup attestation (host/VM remnants not excluded)", phase), Outputs: map[string]string{ "erasure": result.ErasurePath, "erasure_signature": result.SignaturePath, diff --git a/cmd/mpc-ceremony/inspect.go b/cmd/mpc-ceremony/inspect.go index 08fe7c87..60ad5a84 100644 --- a/cmd/mpc-ceremony/inspect.go +++ b/cmd/mpc-ceremony/inspect.go @@ -145,10 +145,7 @@ func inspectDefinition(definition mpcceremony.CeremonyDefinition) DefinitionInsp Mode: definition.Mode, Phase1Participants: append([]string(nil), definition.Phase1Policy.Participants...), Phase2Participants: append([]string(nil), definition.Phase2Policy.Participants...), - HostWipeParticipants: append( - []string(nil), definition.HostWipeParticipants..., - ), - R1CS: definition.Circuit.R1CS, + R1CS: definition.Circuit.R1CS, } } diff --git a/cmd/mpc-ceremony/inspect_test.go b/cmd/mpc-ceremony/inspect_test.go index 39d9f00a..c80913e2 100644 --- a/cmd/mpc-ceremony/inspect_test.go +++ b/cmd/mpc-ceremony/inspect_test.go @@ -88,8 +88,7 @@ func TestInspectCommandsAuthenticateSignedDefinitionAndChain(t *testing.T) { check: func(result CommandResult) bool { return result.DefinitionInspection != nil && result.DefinitionInspection.CeremonyID == definition.CeremonyID && - reflect.DeepEqual(result.DefinitionInspection.Phase1Participants, definition.Phase1Policy.Participants) && - reflect.DeepEqual(result.DefinitionInspection.HostWipeParticipants, definition.HostWipeParticipants) + reflect.DeepEqual(result.DefinitionInspection.Phase1Participants, definition.Phase1Policy.Participants) }, }, { diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 997a09c7..7bb9af87 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -52,7 +52,6 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"inspect", "participant"}, {"inspect", "enrollment"}, {"ops"}, - {"ops", "attest-host-wipe"}, {"ops", "prepare-public-witness-receipt"}, {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, @@ -196,7 +195,6 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--accepted-at", "--allowed-binary", "--contributed-at", - "--wiped-at", } flagPattern := regexp.MustCompile(`--[a-z0-9-]+`) seenSet := make(map[string]struct{}) @@ -224,7 +222,6 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandDecisionPrepare, Options: DecisionPrepareOptions{}}, {Command: CommandDecisionSign, Options: DecisionSignOptions{}}, {Command: CommandDecisionVerify, Options: DecisionVerifyOptions{}}, - {Command: CommandOpsAttestHostWipe, Options: HostWipeOptions{}}, {Command: CommandOpsPreparePublicWitnessReceipt, Options: OpsPreparePublicWitnessReceiptOptions{}}, {Command: CommandOpsPrepareMirrorReceipt, Options: OpsPrepareMirrorReceiptOptions{}}, {Command: CommandInspectDefinition, Options: InspectDefinitionOptions{}}, @@ -269,7 +266,6 @@ func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { {"inspect", "participant"}, {"inspect", "enrollment"}, {"ops", "prepare-public-witness-receipt"}, - {"ops", "attest-host-wipe"}, {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, {"ops", "import-signature"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 0d610f2e..50d84b64 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -274,8 +274,7 @@ command: "chain": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, }, "ops": { - "attest-host-wipe": {}, - "export-signing": {}, "help": {}, "import-signature": {}, + "export-signing": {}, "help": {}, "import-signature": {}, "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "verify": {}, }, "release": {"help": {}, "sign": {}, "verify": {}}, diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index 167660cb..c8ec8ad7 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -18,33 +18,6 @@ import ( const maxOperationalRecordBytes = 16 << 20 -func executeOpsAttestHostWipe(options HostWipeOptions) (CommandResult, error) { - result, err := mpcceremony.CreateHostWipeAttestationFiles( - mpcceremony.CreateHostWipeAttestationFilesOptions{ - Trust: mpcceremony.TrustPaths{ - DefinitionPath: options.CeremonyPath, - DefinitionSignaturePath: options.CeremonySignaturePath, - CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, - }, - ParticipantID: options.ParticipantID, - ParticipantPrivateKeyPath: options.ParticipantSigningKey, - WipedAt: options.WipedAt, - OutDir: options.OutDir, - }, - ) - if err != nil { - return CommandResult{}, err - } - return CommandResult{ - CeremonyID: result.Attestation.CeremonyID, - Summary: "created participant-signed post-wipe macOS host attestation", - Outputs: map[string]string{ - "host_wipe": result.AttestationPath, - "host_wipe_signature": result.SignaturePath, - }, - }, nil -} - func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceiptOptions) (CommandResult, error) { trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ DefinitionPath: options.CeremonyPath, diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index d979df2f..dab986b1 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -432,10 +432,6 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)} } switch args[0] { - case "attest-host-wipe": - options, err := parseHostWipe(args[1:]) - invocation.Command, invocation.Options = CommandOpsAttestHostWipe, options - return invocation, wrapCommandError(err, "ops", "attest-host-wipe") case "prepare-public-witness-receipt": options, err := parseOpsPreparePublicWitnessReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPreparePublicWitnessReceipt, options @@ -464,28 +460,6 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { } } -func parseHostWipe(args []string) (HostWipeOptions, error) { - var options HostWipeOptions - fs := commandFlagSet("ops attest-host-wipe") - addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) - fs.StringVar(&options.ParticipantID, "participant-id", "", "participant identity from the signed host-wipe policy") - fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "participant Ed25519 private key restored from separate storage") - fs.StringVar(&options.WipedAt, "wiped-at", "", "completion time of the whole-device wipe and clean reinstall in RFC3339 UTC") - fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for the signed host-wipe record") - 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), - value("--participant-id", options.ParticipantID), - pathValue("--participant-signing-key", options.ParticipantSigningKey), - value("--wiped-at", options.WipedAt), - pathValue("--out-dir", options.OutDir), - ) -} - func parseOpsPreparePublicWitnessReceipt(args []string) (OpsPreparePublicWitnessReceiptOptions, error) { var options OpsPreparePublicWitnessReceiptOptions fs := commandFlagSet("ops prepare-public-witness-receipt") @@ -611,7 +585,7 @@ func parseOpsVerify(args []string) (OpsVerifyOptions, error) { } func addOpsRecordFlags(fs *flag.FlagSet, recordType, recordPath *string) { - fs.StringVar(recordType, "record-type", "", "enrollment, handoff, receipt, mirror-receipt, public-witness, beacon-evidence, evidence-bundle, governance, or host-wipe") + fs.StringVar(recordType, "record-type", "", "enrollment, handoff, receipt, mirror-receipt, public-witness, beacon-evidence, evidence-bundle, governance") fs.StringVar(recordPath, "record", "", "canonical operational record JSON") } @@ -860,7 +834,7 @@ func parseErasure(name string, args []string) (ErasureOptions, error) { fs.StringVar(&options.ParticipantID, "participant-id", "", "participant identifier from the signed roster") fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "existing Ed25519 participant private key path") fs.StringVar(&options.CandidateDir, "candidate-dir", "", "candidate contribution directory") - fs.StringVar(&options.DestroyedAt, "destroyed-at", "", "environment destruction timestamp in RFC3339") + fs.StringVar(&options.DestroyedAt, "destroyed-at", "", "contributor cleanup completion timestamp in RFC3339 (not physical erasure)") if err := parseFlags(fs, args); err != nil { return options, err } diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 7609199c..739d6a8d 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -38,7 +38,6 @@ const ( CommandReleaseVerify Command = "release verify" CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" - CommandOpsAttestHostWipe Command = "ops attest-host-wipe" CommandOpsExportSigning Command = "ops export-signing" CommandOpsImportSig Command = "ops import-signature" CommandOpsVerify Command = "ops verify" @@ -128,16 +127,6 @@ type ErasureOptions struct { DestroyedAt string } -type HostWipeOptions struct { - CeremonyPath string - CeremonySignaturePath string - CoordinatorPublicKeyFile string - ParticipantID string - ParticipantSigningKey string - WipedAt string - OutDir string -} - type CloseOptions struct { CeremonyPath string CeremonySignaturePath string @@ -331,13 +320,12 @@ type InspectEnrollmentOptions struct { } 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"` - HostWipeParticipants []string `json:"host_wipe_participants,omitempty"` - R1CS mpcceremony.ArtifactRef `json:"r1cs"` + 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 { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index d194ed0e..21eff171 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -35,14 +35,14 @@ Commands: 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 + phase1 attest-erasure Sign a participant cleanup claim (not physical erasure) phase1 verify Verify and append one candidate contribution phase1 close Close the accepted phase 1 chain phase1 beacon Record signed post-closure beacon evidence phase1 seal Apply an offline post-closure beacon phase2 init Initialize circuit-specific phase 2 phase2 contribute Verify the full phase 2 chain and contribute - phase2 attest-erasure Sign a participant destruction attestation + phase2 attest-erasure Sign a participant cleanup claim (not physical erasure) phase2 verify Verify and append one candidate contribution phase2 close Close the accepted phase 2 chain phase2 beacon Record signed post-closure beacon evidence @@ -58,7 +58,6 @@ Commands: 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 attest-host-wipe Sign a post-wipe macOS host attestation 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 @@ -203,9 +202,7 @@ definition. The authoritative ceremony ID is derived from canonical content, including a 32-byte session nonce securely generated when omitted. Production mode requires exact clean source builds. The running binary is always allowed; each repeated --allowed-binary adds one authenticated binary for another -platform to the signed definition. For production Mac contributors, the -participants file contains a sorted host_wipe_participants list. Those -identities must later submit signed post-wipe evidence before release. +platform to the signed definition. `, "phase1": `Usage: mpc-ceremony phase1 [flags] @@ -232,6 +229,9 @@ randomness. The input chain is never modified. Writes erasure.json and its participant signature into the candidate directory without replacing existing files. This is an operational attestation, not technical or cryptographic proof that contribution randomness was erased. +Sign only after the contributor process has terminated, its ephemeral +environment was removed, and the participant confirmed no deliberate copies. +Host/VM remnants are explicitly not excluded by this statement. `, "phase1 verify": `Usage: mpc-ceremony phase1 verify --ceremony FILE --ceremony-signature FILE \ @@ -305,7 +305,8 @@ Phase 2 is bound to the exact compiled R1CS and verified phase 1 seal. --participant-id ID --participant-signing-key KEY \ --candidate-dir DIR --destroyed-at RFC3339 -Signs the participant's Phase 2 environment-destruction attestation. The +Signs the participant's Phase 2 logical-cleanup attestation. Host/VM remnants +are explicitly not excluded; confirm cleanup precautions before signing. The statement is auditable evidence, not proof that secret randomness was erased. `, "phase2 verify": `Usage: @@ -461,24 +462,11 @@ 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 attest-host-wipe": `Usage: - mpc-ceremony ops attest-host-wipe --ceremony FILE \ - --ceremony-signature FILE --coordinator-public-key-file KEY \ - --participant-id ID --participant-signing-key KEY \ - --wiped-at RFC3339 --out-dir DIR - -Run this only after the Mac used for a production contribution has undergone -a supported whole-device erase and clean macOS reinstall. Do not restore old -Docker Desktop data, snapshots, backups, or contribution copies. The signed -record is an authenticated honest-participant claim, not physical proof of -erasure. Release verification rejects a required record that does not postdate -the participant's final contribution. `, "ops prepare-public-witness-receipt": `Usage: mpc-ceremony ops prepare-public-witness-receipt \ diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index cf73de3b..e9684acb 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -39,13 +39,13 @@ versions, compiler/build policies, dirty states, or multiple binaries for one platform. The signed definition records the full exact-digest allowlist; legacy v1 definitions remain one-binary ceremonies. -The same v2 definition may freeze a sorted production Mac wipe policy through -the participant input's `host_wipe_participants` field. The operational -evidence bundle schema v2 carries the corresponding signed host-wipe records. -Release signing recursively verifies that every required record belongs to the -rostered participant and postdates that participant's final contribution, so -accepted contributions can remain provisional without allowing premature -parameter release. +Contribution and cleanup attestations use schema v2 with contributor-scoped +controls and explicit acknowledgement of unexcluded host/VM remnants. Whole- +machine wipe policies and separate wipe records are removed, without legacy +support. Release signing still recursively verifies signed cleanup records, +contribution binding, timing, audits, witnesses, mirrors, and beacon evidence. +Use matching new Relay/proof-tool binaries and regenerate environment inputs; +old attestation schemas and removed wipe fields are rejected. ## Coordinated distribution diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index 362253c5..369e8413 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -5,8 +5,8 @@ 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 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) + [coordinator runbook](https://github.com/zksecurity/relay/blob/main/docs/roles/coordinator.md) + and [role runbook](https://github.com/zksecurity/relay/blob/main/docs/README.md) document the distributed transport and operator workflow. The commands, transcripts, and trust claims are not interchangeable. @@ -25,13 +25,10 @@ allowlist member, and every contribution attestation records the digest that actually ran. A v1 definition is intentionally interpreted as a singleton allowlist. -For a production ceremony that permits macOS participants through Docker, the -canonical participant input also freezes a sorted `host_wipe_participants` -list into the v2 signed definition. Their contributions may be accepted before -the whole Mac is erased, but final operational-evidence and release -verification require a participant-signed post-wipe record that is later than -that participant's final accepted contribution. This is authenticated -honest-participant evidence, not physical proof that no earlier copy exists. +Production Mac and Linux contributors use guided Docker cleanup and participant +confirmation. Whole-machine wiping and separate post-wipe records are not +required. This accepts residual host/VM memory and storage risk; container +removal does not establish that no secret copy survived. ## Single-Actor Local Setup @@ -84,8 +81,8 @@ 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 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) +[coordinator runbook](https://github.com/zksecurity/relay/blob/main/docs/roles/coordinator.md) +and [role runbook](https://github.com/zksecurity/relay/blob/main/docs/README.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 @@ -107,10 +104,13 @@ independent contributor in each phase, but it does not cryptographically prove that a contributor erased its randomness. Every accepted participant must use and attest to the host controls in the MPC runbook. -For a participant named by the signed production Mac wipe policy, the immediate -container-erasure record permits contribution acceptance but is not the final -host-level gate. After the participant's last contribution, the whole Mac is -erased and cleanly reinstalled without restoring backups, snapshots, Docker -Desktop state, or contribution copies. The participant then runs `mpc-ceremony -ops attest-host-wipe` through Relay's guided flow. Release verification rejects -a missing, duplicate, invalid, or too-early required record. +The v2 contribution environment describes contributor-scoped swap, dump, and +telemetry controls. Both the environment and signed cleanup record require +`host_remnants_not_excluded: true`. The cleanup record authenticates process +termination, logical ephemeral-environment removal, and the participant's +confirmation of no deliberate retained copies. It is not secure physical +erasure evidence. Relay checks Docker lifecycle facts; proof-tool verifies the +signed claims and their binding to the contribution, output, and timestamp. +Host/VM swap, backups, snapshots, or a compromised host may retain secrets even +when honest participants follow every instruction. Dedicated controlled +environments can reduce risk but cannot undo an earlier leak. diff --git a/internal/mpcceremony/adversarial_test.go b/internal/mpcceremony/adversarial_test.go index b1c353ad..c843b0b5 100644 --- a/internal/mpcceremony/adversarial_test.go +++ b/internal/mpcceremony/adversarial_test.go @@ -130,14 +130,15 @@ func adversarialAttestation(t *testing.T) ContributionAttestation { GnarkCryptoVersion: GnarkCryptoVersion, DrandVersion: DrandVersion, Environment: ContributionEnvironment{ - OS: "linux", - Architecture: "amd64", - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, + OS: "linux", + Architecture: "amd64", + EntropySource: "operating-system-csprng", + ContributorSwapDisabled: true, + ContributorCrashDumpsDisabled: true, + ContributorTelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralCleanupRequired: true, + HostRemnantsNotExcluded: true, }, ContributedAt: "2026-07-23T12:00:00Z", }) @@ -169,18 +170,19 @@ func adversarialErasure( ) ErasureAttestation { t.Helper() erasure, err := NewErasureAttestation(ErasureAttestation{ - CeremonyID: contribution.CeremonyID, - Phase: contribution.Phase, - PhaseID: contribution.PhaseID, - Index: contribution.Index, - ParticipantID: contribution.ParticipantID, - ParticipantKeyID: contribution.ParticipantKeyID, - ContributionAttestationID: contribution.AttestationID, - OutputPayload: contribution.OutputPayload, - DestroyedAt: destroyedAt, - ProcessTerminated: true, - EphemeralStorageDestroyed: true, - NoBackupRetained: true, + CeremonyID: contribution.CeremonyID, + Phase: contribution.Phase, + PhaseID: contribution.PhaseID, + Index: contribution.Index, + ParticipantID: contribution.ParticipantID, + ParticipantKeyID: contribution.ParticipantKeyID, + ContributionAttestationID: contribution.AttestationID, + OutputPayload: contribution.OutputPayload, + DestroyedAt: destroyedAt, + ProcessTerminated: true, + EphemeralEnvironmentRemoved: true, + NoDeliberateCopiesConfirmed: true, + HostRemnantsNotExcluded: true, }) if err != nil { t.Fatalf("create erasure attestation: %v", err) @@ -795,14 +797,15 @@ func TestAcceptanceRecordMustExactlyBindAttestation(t *testing.T) { GnarkCryptoVersion: definition.Software.GnarkCryptoVersion, DrandVersion: definition.Software.DrandVersion, Environment: ContributionEnvironment{ - OS: "linux", - Architecture: "amd64", - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, + OS: "linux", + Architecture: "amd64", + EntropySource: "operating-system-csprng", + ContributorSwapDisabled: true, + ContributorCrashDumpsDisabled: true, + ContributorTelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralCleanupRequired: true, + HostRemnantsNotExcluded: true, }, ContributedAt: "2026-07-23T12:01:00Z", }) @@ -1024,7 +1027,7 @@ func TestErasureAttestationRequiresExactPostContributionDestruction(t *testing.T } incomplete := erasure - incomplete.NoBackupRetained = false + incomplete.NoDeliberateCopiesConfirmed = false if _, err := NewErasureAttestation(incomplete); err == nil { t.Fatal("erasure with a retained backup unexpectedly accepted") } diff --git a/internal/mpcceremony/attestation.go b/internal/mpcceremony/attestation.go index 2725242a..8574b0f1 100644 --- a/internal/mpcceremony/attestation.go +++ b/internal/mpcceremony/attestation.go @@ -131,15 +131,19 @@ func VerifySignedRecord(recordBytes, signatureBytes []byte, destination any, exp return nil } +// ContributionEnvironment describes controls within the contributor execution +// environment, not the surrounding host or VM. These are signed operator claims. +// HostRemnantsNotExcluded acknowledges that physical erasure is not established. type ContributionEnvironment struct { - OS string `json:"os"` - Architecture string `json:"architecture"` - EntropySource string `json:"entropy_source"` - SwapDisabled bool `json:"swap_disabled"` - CrashDumpsDisabled bool `json:"crash_dumps_disabled"` - TelemetryDisabled bool `json:"telemetry_disabled"` - EphemeralEnvironment bool `json:"ephemeral_environment"` - EphemeralDestructionRequired bool `json:"ephemeral_destruction_required"` + OS string `json:"os"` + Architecture string `json:"architecture"` + EntropySource string `json:"entropy_source"` + ContributorSwapDisabled bool `json:"contributor_swap_disabled"` + ContributorCrashDumpsDisabled bool `json:"contributor_crash_dumps_disabled"` + ContributorTelemetryDisabled bool `json:"contributor_telemetry_disabled"` + EphemeralEnvironment bool `json:"ephemeral_environment"` + EphemeralCleanupRequired bool `json:"ephemeral_cleanup_required"` + HostRemnantsNotExcluded bool `json:"host_remnants_not_excluded"` } func (e ContributionEnvironment) Validate() error { @@ -150,28 +154,31 @@ func (e ContributionEnvironment) Validate() error { if e.EntropySource != "operating-system-csprng" { return fmt.Errorf("entropy_source %q, want operating-system-csprng", e.EntropySource) } - if !e.SwapDisabled || !e.CrashDumpsDisabled || !e.TelemetryDisabled || - !e.EphemeralEnvironment || !e.EphemeralDestructionRequired { - return errors.New("all production contribution environment controls and the post-contribution destruction plan must be attested") + if !e.ContributorSwapDisabled || !e.ContributorCrashDumpsDisabled || !e.ContributorTelemetryDisabled || + !e.EphemeralEnvironment || !e.EphemeralCleanupRequired || !e.HostRemnantsNotExcluded { + return errors.New("contributor-scoped controls, cleanup plan, and unexcluded host/VM remnants must be acknowledged") } return nil } +// ErasureAttestation records logical cleanup and participant precautions. It +// does not prove zeroization, secure disk erasure, or the absence of host copies. type ErasureAttestation struct { - Schema string `json:"schema"` - ErasureID string `json:"erasure_id"` - CeremonyID string `json:"ceremony_id"` - Phase Phase `json:"phase"` - PhaseID string `json:"phase_id"` - Index uint8 `json:"index"` - ParticipantID string `json:"participant_id"` - ParticipantKeyID string `json:"participant_key_id"` - ContributionAttestationID string `json:"contribution_attestation_id"` - OutputPayload ArtifactRef `json:"output_payload"` - DestroyedAt string `json:"destroyed_at"` - ProcessTerminated bool `json:"process_terminated"` - EphemeralStorageDestroyed bool `json:"ephemeral_storage_destroyed"` - NoBackupRetained bool `json:"no_backup_retained"` + Schema string `json:"schema"` + ErasureID string `json:"erasure_id"` + CeremonyID string `json:"ceremony_id"` + Phase Phase `json:"phase"` + PhaseID string `json:"phase_id"` + Index uint8 `json:"index"` + ParticipantID string `json:"participant_id"` + ParticipantKeyID string `json:"participant_key_id"` + ContributionAttestationID string `json:"contribution_attestation_id"` + OutputPayload ArtifactRef `json:"output_payload"` + DestroyedAt string `json:"destroyed_at"` + ProcessTerminated bool `json:"process_terminated"` + EphemeralEnvironmentRemoved bool `json:"ephemeral_environment_removed"` + NoDeliberateCopiesConfirmed bool `json:"no_deliberate_copies_confirmed"` + HostRemnantsNotExcluded bool `json:"host_remnants_not_excluded"` } func NewErasureAttestation(attestation ErasureAttestation) (ErasureAttestation, error) { @@ -193,7 +200,7 @@ func ComputeErasureAttestationID(attestation ErasureAttestation) (string, error) if err := attestation.validate(false); err != nil { return "", err } - return canonicalHash("proof-tool/mpc-ceremony/erasure-attestation/v1", attestation) + return canonicalHash("proof-tool/mpc-ceremony/erasure-attestation/v2", attestation) } func (a ErasureAttestation) Validate() error { @@ -248,8 +255,8 @@ func (a ErasureAttestation) validate(requireID bool) error { if err := validateTimestamp("destroyed_at", a.DestroyedAt); err != nil { return err } - if !a.ProcessTerminated || !a.EphemeralStorageDestroyed || !a.NoBackupRetained { - return errors.New("erasure attestation requires process termination, ephemeral storage destruction, and no retained backup") + if !a.ProcessTerminated || !a.EphemeralEnvironmentRemoved || !a.NoDeliberateCopiesConfirmed || !a.HostRemnantsNotExcluded { + return errors.New("cleanup attestation requires process termination, ephemeral environment removal, no-deliberate-copy confirmation, and acknowledgement of unexcluded host/VM remnants") } return nil } @@ -321,7 +328,7 @@ func ComputeContributionAttestationID(attestation ContributionAttestation) (stri if err := attestation.validate(false); err != nil { return "", err } - return canonicalHash("proof-tool/mpc-ceremony/contribution-attestation/v1", attestation) + return canonicalHash("proof-tool/mpc-ceremony/contribution-attestation/v2", attestation) } func (a ContributionAttestation) Validate() error { diff --git a/internal/mpcceremony/attestation_test.go b/internal/mpcceremony/attestation_test.go index ff767d81..c5bdb707 100644 --- a/internal/mpcceremony/attestation_test.go +++ b/internal/mpcceremony/attestation_test.go @@ -7,18 +7,19 @@ import ( func TestErasureAttestationBindsCompletedPostContributionDestruction(t *testing.T) { contribution := adversarialAttestation(t) erasure, err := NewErasureAttestation(ErasureAttestation{ - CeremonyID: contribution.CeremonyID, - Phase: contribution.Phase, - PhaseID: contribution.PhaseID, - Index: contribution.Index, - ParticipantID: contribution.ParticipantID, - ParticipantKeyID: contribution.ParticipantKeyID, - ContributionAttestationID: contribution.AttestationID, - OutputPayload: contribution.OutputPayload, - DestroyedAt: "2026-07-23T12:00:01Z", - ProcessTerminated: true, - EphemeralStorageDestroyed: true, - NoBackupRetained: true, + CeremonyID: contribution.CeremonyID, + Phase: contribution.Phase, + PhaseID: contribution.PhaseID, + Index: contribution.Index, + ParticipantID: contribution.ParticipantID, + ParticipantKeyID: contribution.ParticipantKeyID, + ContributionAttestationID: contribution.AttestationID, + OutputPayload: contribution.OutputPayload, + DestroyedAt: "2026-07-23T12:00:01Z", + ProcessTerminated: true, + EphemeralEnvironmentRemoved: true, + NoDeliberateCopiesConfirmed: true, + HostRemnantsNotExcluded: true, }) if err != nil { t.Fatalf("new erasure attestation: %v", err) @@ -54,26 +55,28 @@ func TestErasureAttestationBindsCompletedPostContributionDestruction(t *testing. func TestErasureAttestationRequiresAllNarrowClaims(t *testing.T) { contribution := adversarialAttestation(t) base := ErasureAttestation{ - CeremonyID: contribution.CeremonyID, - Phase: contribution.Phase, - PhaseID: contribution.PhaseID, - Index: contribution.Index, - ParticipantID: contribution.ParticipantID, - ParticipantKeyID: contribution.ParticipantKeyID, - ContributionAttestationID: contribution.AttestationID, - OutputPayload: contribution.OutputPayload, - DestroyedAt: "2026-07-23T12:01:00Z", - ProcessTerminated: true, - EphemeralStorageDestroyed: true, - NoBackupRetained: true, + CeremonyID: contribution.CeremonyID, + Phase: contribution.Phase, + PhaseID: contribution.PhaseID, + Index: contribution.Index, + ParticipantID: contribution.ParticipantID, + ParticipantKeyID: contribution.ParticipantKeyID, + ContributionAttestationID: contribution.AttestationID, + OutputPayload: contribution.OutputPayload, + DestroyedAt: "2026-07-23T12:01:00Z", + ProcessTerminated: true, + EphemeralEnvironmentRemoved: true, + NoDeliberateCopiesConfirmed: true, + HostRemnantsNotExcluded: true, } cases := []struct { name string mutate func(*ErasureAttestation) }{ {"process", func(a *ErasureAttestation) { a.ProcessTerminated = false }}, - {"storage", func(a *ErasureAttestation) { a.EphemeralStorageDestroyed = false }}, - {"backup", func(a *ErasureAttestation) { a.NoBackupRetained = false }}, + {"storage", func(a *ErasureAttestation) { a.EphemeralEnvironmentRemoved = false }}, + {"backup", func(a *ErasureAttestation) { a.NoDeliberateCopiesConfirmed = false }}, + {"unassessed host remnants", func(a *ErasureAttestation) { a.HostRemnantsNotExcluded = false }}, } for _, test := range cases { t.Run(test.name, func(t *testing.T) { @@ -85,3 +88,14 @@ func TestErasureAttestationRequiresAllNarrowClaims(t *testing.T) { }) } } + +func TestCleanupEnvironmentAcknowledgesHostRisk(t *testing.T) { + e := adversarialAttestation(t).Environment + if err := e.Validate(); err != nil { + t.Fatal(err) + } + e.HostRemnantsNotExcluded = false + if err := e.Validate(); err == nil { + t.Fatal("environment that omits host-remnant acknowledgement accepted") + } +} diff --git a/internal/mpcceremony/cleanup_policy_test.go b/internal/mpcceremony/cleanup_policy_test.go new file mode 100644 index 00000000..b0d5fe7f --- /dev/null +++ b/internal/mpcceremony/cleanup_policy_test.go @@ -0,0 +1,41 @@ +package mpcceremony + +import ( + "strings" + "testing" +) + +func TestRemovedWipeFieldsAreRejected(t *testing.T) { + definition := adversarialDefinition(t) + raw, err := MarshalCanonical(definition) + if err != nil { + t.Fatal(err) + } + withWipe := strings.Replace(string(raw), "{", `{"host_wipe_participants":[],`, 1) + var parsed CeremonyDefinition + if err := UnmarshalCanonical([]byte(withWipe), &parsed); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("removed policy accepted: %v", err) + } + var bundle OperationalEvidenceBundle + if err := UnmarshalCanonical([]byte(`{"host_wipes":[]}`), &bundle); err == nil || !strings.Contains(err.Error(), "unknown field") { + t.Fatalf("removed evidence field accepted: %v", err) + } +} + +func TestOldBroadCleanupClaimsAreRejected(t *testing.T) { + var environment ContributionEnvironment + if err := UnmarshalCanonical([]byte(`{"swap_disabled":true}`), &environment); err == nil { + t.Fatal("unscoped swap assertion accepted") + } + var cleanup ErasureAttestation + for _, raw := range []string{`{"no_backup_retained":true}`, `{"ephemeral_storage_destroyed":true}`} { + if err := UnmarshalCanonical([]byte(raw), &cleanup); err == nil { + t.Fatal("old broad cleanup assertion accepted") + } + } + contribution := adversarialAttestation(t) + contribution.Schema = "proof-tool-mpc-contribution-attestation-v1" + if err := contribution.Validate(); err == nil { + t.Fatal("old contribution schema accepted") + } +} diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index 009f9fbd..4e75becf 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -3,7 +3,6 @@ package mpcceremony import ( "errors" "fmt" - "slices" ) const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 @@ -24,39 +23,37 @@ const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 const ProductionWitnessObservationWindowSeconds uint32 = 60 * 60 type CeremonyDefinition struct { - Schema string `json:"schema"` - CeremonyID string `json:"ceremony_id"` - Mode string `json:"mode"` - CreatedAt string `json:"created_at"` - SessionNonceHex string `json:"session_nonce_hex"` - Circuit CircuitBinding `json:"circuit"` - Software SoftwareBinding `json:"software"` - Coordinator Identity `json:"coordinator"` - ReleaseSigner Identity `json:"release_signer"` - Auditors []Identity `json:"auditors"` - Roster []Participant `json:"roster"` - HostWipeParticipants []string `json:"host_wipe_participants,omitempty"` - Phase1Policy PhasePolicy `json:"phase1_policy"` - Phase2Policy PhasePolicy `json:"phase2_policy"` - BeaconPolicy BeaconPolicy `json:"beacon_policy"` - Phase1Genesis ArtifactRef `json:"phase1_genesis"` + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + CreatedAt string `json:"created_at"` + SessionNonceHex string `json:"session_nonce_hex"` + Circuit CircuitBinding `json:"circuit"` + Software SoftwareBinding `json:"software"` + Coordinator Identity `json:"coordinator"` + ReleaseSigner Identity `json:"release_signer"` + Auditors []Identity `json:"auditors"` + Roster []Participant `json:"roster"` + Phase1Policy PhasePolicy `json:"phase1_policy"` + Phase2Policy PhasePolicy `json:"phase2_policy"` + BeaconPolicy BeaconPolicy `json:"beacon_policy"` + Phase1Genesis ArtifactRef `json:"phase1_genesis"` } type DefinitionOptions struct { - Mode string - CreatedAt string - SessionNonceHex string - Circuit CircuitBinding - Software SoftwareBinding - Coordinator Identity - ReleaseSigner Identity - Auditors []Identity - Roster []Participant - HostWipeParticipants []string - Phase1Policy PhasePolicy - Phase2Policy PhasePolicy - BeaconPolicy BeaconPolicy - Phase1Genesis ArtifactRef + Mode string + CreatedAt string + SessionNonceHex string + Circuit CircuitBinding + Software SoftwareBinding + Coordinator Identity + ReleaseSigner Identity + Auditors []Identity + Roster []Participant + Phase1Policy PhasePolicy + Phase2Policy PhasePolicy + BeaconPolicy BeaconPolicy + Phase1Genesis ArtifactRef } func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error) { @@ -65,21 +62,20 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error software.Binaries = []SoftwareBinary{software.primaryBinary()} } definition := CeremonyDefinition{ - Schema: DefinitionSchema, - Mode: options.Mode, - CreatedAt: options.CreatedAt, - SessionNonceHex: options.SessionNonceHex, - Circuit: options.Circuit, - Software: software, - Coordinator: options.Coordinator, - ReleaseSigner: options.ReleaseSigner, - Auditors: append([]Identity(nil), options.Auditors...), - Roster: append([]Participant(nil), options.Roster...), - HostWipeParticipants: append([]string(nil), options.HostWipeParticipants...), - Phase1Policy: clonePhasePolicy(options.Phase1Policy), - Phase2Policy: clonePhasePolicy(options.Phase2Policy), - BeaconPolicy: options.BeaconPolicy, - Phase1Genesis: options.Phase1Genesis, + Schema: DefinitionSchema, + Mode: options.Mode, + CreatedAt: options.CreatedAt, + SessionNonceHex: options.SessionNonceHex, + Circuit: options.Circuit, + Software: software, + Coordinator: options.Coordinator, + ReleaseSigner: options.ReleaseSigner, + Auditors: append([]Identity(nil), options.Auditors...), + Roster: append([]Participant(nil), options.Roster...), + Phase1Policy: clonePhasePolicy(options.Phase1Policy), + Phase2Policy: clonePhasePolicy(options.Phase2Policy), + BeaconPolicy: options.BeaconPolicy, + Phase1Genesis: options.Phase1Genesis, } id, err := ComputeCeremonyID(definition) if err != nil { @@ -142,7 +138,7 @@ func (d CeremonyDefinition) validate(requireID bool) error { switch d.Schema { case DefinitionSchema: case DefinitionSchemaV1: - if len(d.Software.Binaries) != 0 || d.Software.GoARM64 != "" || len(d.HostWipeParticipants) != 0 { + if len(d.Software.Binaries) != 0 || d.Software.GoARM64 != "" { return errors.New("definition v1 must not contain v2-only fields") } default: @@ -305,26 +301,6 @@ func (d CeremonyDefinition) validate(requireID bool) error { keyIDs[keyID] = "participant" publicKeyFingerprints[participant.Identity.PublicKeyFingerprint] = "participant" } - if len(d.HostWipeParticipants) > len(d.Roster) { - return errors.New("host_wipe_participants cannot exceed the signed roster") - } - if !slices.IsSorted(d.HostWipeParticipants) { - return errors.New("host_wipe_participants must be sorted") - } - for index, id := range d.HostWipeParticipants { - if index > 0 && id == d.HostWipeParticipants[index-1] { - return errors.New("host_wipe_participants must not contain duplicates") - } - if _, ok := roster[id]; !ok { - return fmt.Errorf("host-wipe participant %q is not in the signed roster", id) - } - if !slices.Contains(d.Phase1Policy.Participants, id) && !slices.Contains(d.Phase2Policy.Participants, id) { - return fmt.Errorf("host-wipe participant %q is not scheduled in either phase", id) - } - } - if d.Mode == ModeRehearsal && len(d.HostWipeParticipants) != 0 { - return errors.New("rehearsal ceremony must not require production host wipes") - } if err := d.Phase1Policy.Validate(roster); err != nil { return fmt.Errorf("phase1_policy: %w", err) } diff --git a/internal/mpcceremony/host_wipe.go b/internal/mpcceremony/host_wipe.go deleted file mode 100644 index 0bc9080c..00000000 --- a/internal/mpcceremony/host_wipe.go +++ /dev/null @@ -1,221 +0,0 @@ -package mpcceremony - -import ( - "errors" - "fmt" - "os" - "path/filepath" - "slices" - "time" -) - -const ( - HostWipeAttestationSchema = "proof-tool-mpc-host-wipe-attestation-v1" - HostWipeRecordFile = "host-wipe.json" - HostWipeSignatureFile = "host-wipe.sig" -) - -// HostWipeAttestation is an authenticated participant claim made only after -// the Mac used for contribution has been erased. It is intentionally not -// described as physical proof: software cannot rule out a copy made before -// the wipe. Production release verification uses it as an honest-operator -// gate and checks that it postdates the participant's final contribution. -type HostWipeAttestation struct { - Schema string `json:"schema"` - HostWipeID string `json:"host_wipe_id"` - CeremonyID string `json:"ceremony_id"` - ParticipantID string `json:"participant_id"` - ParticipantKeyID string `json:"participant_key_id"` - HostOS string `json:"host_os"` - WholeDeviceErased bool `json:"whole_device_erased"` - OperatingSystemReinstalled bool `json:"operating_system_reinstalled"` - NoPreWipeSystemBackupOrSnapshotRestored bool `json:"no_pre_wipe_system_backup_or_snapshot_restored"` - NoDockerDesktopStateRestored bool `json:"no_docker_desktop_state_restored"` - NoContributionRandomnessCopyRetained bool `json:"no_contribution_randomness_copy_retained"` - WipedAt string `json:"wiped_at"` -} - -func NewHostWipeAttestation(value HostWipeAttestation) (HostWipeAttestation, error) { - value.Schema = HostWipeAttestationSchema - value.HostWipeID = "" - id, err := ComputeHostWipeAttestationID(value) - if err != nil { - return HostWipeAttestation{}, err - } - value.HostWipeID = id - return value, value.Validate() -} - -func ComputeHostWipeAttestationID(value HostWipeAttestation) (string, error) { - value.HostWipeID = "" - if err := value.validate(false); err != nil { - return "", err - } - return canonicalHash("proof-tool/mpc-ceremony/host-wipe-attestation/v1", value) -} - -func (a HostWipeAttestation) Validate() error { - if err := a.validate(true); err != nil { - return err - } - expected, err := ComputeHostWipeAttestationID(a) - if err != nil { - return err - } - if a.HostWipeID != expected { - return fmt.Errorf("host_wipe_id %q, want %q", a.HostWipeID, expected) - } - return nil -} - -func (a HostWipeAttestation) validate(requireID bool) error { - if a.Schema != HostWipeAttestationSchema { - return fmt.Errorf("host-wipe schema %q, want %q", a.Schema, HostWipeAttestationSchema) - } - if requireID { - if err := validateHashID("host_wipe_id", a.HostWipeID); err != nil { - return err - } - } else if a.HostWipeID != "" { - return errors.New("host_wipe_id must be empty while computing identity") - } - if err := validateHashID("ceremony_id", a.CeremonyID); err != nil { - return err - } - if err := validateID("participant_id", a.ParticipantID); err != nil { - return err - } - if err := validateID("participant_key_id", a.ParticipantKeyID); err != nil { - return err - } - if a.HostOS != "darwin" { - return fmt.Errorf("host_os %q, want darwin", a.HostOS) - } - if !a.WholeDeviceErased || !a.OperatingSystemReinstalled || - !a.NoPreWipeSystemBackupOrSnapshotRestored || !a.NoDockerDesktopStateRestored || - !a.NoContributionRandomnessCopyRetained { - return errors.New("all host-wipe assertions must be true") - } - return validateTimestamp("wiped_at", a.WipedAt) -} - -func VerifyHostWipeAttestation( - definition CeremonyDefinition, - recordBytes, signatureBytes []byte, -) (HostWipeAttestation, error) { - if err := definition.Validate(); err != nil { - return HostWipeAttestation{}, err - } - var record HostWipeAttestation - if err := UnmarshalCanonical(recordBytes, &record); err != nil { - return HostWipeAttestation{}, err - } - if record.CeremonyID != definition.CeremonyID || - !slices.Contains(definition.HostWipeParticipants, record.ParticipantID) { - return HostWipeAttestation{}, errors.New("host-wipe attestation is not required by this ceremony and participant") - } - participant, ok := definition.ParticipantByID(record.ParticipantID) - if !ok || participant.Identity.KeyID != record.ParticipantKeyID { - return HostWipeAttestation{}, errors.New("host-wipe participant identity does not match the signed roster") - } - publicKey, err := identityPublicKey(participant.Identity) - if err != nil { - return HostWipeAttestation{}, err - } - if err := VerifySignedRecord( - recordBytes, - signatureBytes, - &record, - participant.Identity.KeyID, - publicKey, - ); err != nil { - return HostWipeAttestation{}, err - } - if err := record.Validate(); err != nil { - return HostWipeAttestation{}, err - } - return record, nil -} - -type CreateHostWipeAttestationFilesOptions struct { - Trust TrustPaths - ParticipantID string - ParticipantPrivateKeyPath string - WipedAt string - OutDir string -} - -type CreateHostWipeAttestationFilesResult struct { - Attestation HostWipeAttestation - AttestationPath string - SignaturePath string -} - -func CreateHostWipeAttestationFiles( - options CreateHostWipeAttestationFilesOptions, -) (result CreateHostWipeAttestationFilesResult, err error) { - trusted, err := loadOperationalCeremony(options.Trust) - if err != nil { - return result, err - } - if trusted.Definition.Mode != ModeProduction { - return result, errors.New("host-wipe attestations apply only to production ceremonies") - } - if !slices.Contains(trusted.Definition.HostWipeParticipants, options.ParticipantID) { - return result, errors.New("participant is not required to provide a host-wipe attestation") - } - participant, ok := trusted.Definition.ParticipantByID(options.ParticipantID) - if !ok { - return result, errors.New("host-wipe participant is not in the signed roster") - } - privateKey, _, err := loadMatchingPrivateKey(options.ParticipantPrivateKeyPath, participant.Identity) - if err != nil { - return result, fmt.Errorf("participant signing key: %w", err) - } - wipedAt, err := time.Parse(time.RFC3339Nano, options.WipedAt) - if err != nil { - return result, errors.New("wiped_at must be RFC3339") - } - createdAt, _ := time.Parse(time.RFC3339Nano, trusted.Definition.CreatedAt) - if !wipedAt.After(createdAt) { - return result, errors.New("wiped_at must strictly postdate ceremony creation") - } - record, err := NewHostWipeAttestation(HostWipeAttestation{ - CeremonyID: trusted.Definition.CeremonyID, - ParticipantID: participant.Identity.ID, - ParticipantKeyID: participant.Identity.KeyID, - HostOS: "darwin", - WholeDeviceErased: true, - OperatingSystemReinstalled: true, - NoPreWipeSystemBackupOrSnapshotRestored: true, - NoDockerDesktopStateRestored: true, - NoContributionRandomnessCopyRetained: true, - WipedAt: wipedAt.UTC().Format(time.RFC3339Nano), - }) - if err != nil { - return result, err - } - if err := os.Mkdir(options.OutDir, 0o700); err != nil { - return result, fmt.Errorf("create fresh host-wipe output directory: %w", err) - } - created := true - defer func() { - if err != nil && created { - _ = os.RemoveAll(options.OutDir) - } - }() - result.AttestationPath = filepath.Join(options.OutDir, HostWipeRecordFile) - result.SignaturePath = filepath.Join(options.OutDir, HostWipeSignatureFile) - if err := writeSignedRecordNoReplace( - result.AttestationPath, - result.SignaturePath, - record, - participant.Identity.KeyID, - privateKey, - ); err != nil { - return result, err - } - result.Attestation = record - created = false - return result, nil -} diff --git a/internal/mpcceremony/host_wipe_test.go b/internal/mpcceremony/host_wipe_test.go deleted file mode 100644 index 9f5ec393..00000000 --- a/internal/mpcceremony/host_wipe_test.go +++ /dev/null @@ -1,224 +0,0 @@ -package mpcceremony - -import ( - "os" - "path/filepath" - "strings" - "testing" - "time" -) - -func TestHostWipeAttestationAndReleaseGate(t *testing.T) { - definition := adversarialDefinition(t) - definition.HostWipeParticipants = []string{"participant-01"} - var err error - definition, err = FinalizeCeremonyDefinition(definition) - if err != nil { - t.Fatal(err) - } - record, err := NewHostWipeAttestation(HostWipeAttestation{ - CeremonyID: definition.CeremonyID, - ParticipantID: "participant-01", - ParticipantKeyID: definition.Roster[0].Identity.KeyID, - HostOS: "darwin", - WholeDeviceErased: true, - OperatingSystemReinstalled: true, - NoPreWipeSystemBackupOrSnapshotRestored: true, - NoDockerDesktopStateRestored: true, - NoContributionRandomnessCopyRetained: true, - WipedAt: "2026-07-23T15:00:00Z", - }) - if err != nil { - t.Fatal(err) - } - recordBytes, signatureBytes, err := SignRecord( - record, - definition.Roster[0].Identity.KeyID, - adversarialPrivateKey(0x11), - ) - if err != nil { - t.Fatal(err) - } - verified, err := VerifyHostWipeAttestation(definition, recordBytes, signatureBytes) - if err != nil || verified.HostWipeID != record.HostWipeID { - t.Fatalf("verify host wipe = %#v, %v", verified, err) - } - tamperedSignature := append([]byte(nil), signatureBytes...) - tamperedSignature[len(tamperedSignature)-1] ^= 1 - if _, err := VerifyHostWipeAttestation(definition, recordBytes, tamperedSignature); err == nil { - t.Fatal("tampered host-wipe signature accepted") - } - - root, err := filepath.EvalSymlinks(t.TempDir()) - if err != nil { - t.Fatal(err) - } - write := func(name string, raw []byte) ArtifactRef { - t.Helper() - path := filepath.Join(root, filepath.FromSlash(name)) - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - t.Fatal(err) - } - if err := os.WriteFile(path, raw, 0o600); err != nil { - t.Fatal(err) - } - return ArtifactRef{Name: name, Digest: NewDigest(raw)} - } - contribution, err := NewContributionAttestation(ContributionAttestation{ - CeremonyID: definition.CeremonyID, Phase: Phase2, - PhaseID: "sha256:" + strings.Repeat("c", 64), Index: 1, - ParticipantID: "participant-01", ParticipantKeyID: definition.Roster[0].Identity.KeyID, - PreviousPayload: ArtifactRef{Name: "phase2/genesis.bin", Digest: NewDigest([]byte("before"))}, - OutputPayload: ArtifactRef{Name: "phase2/contribution.bin", Digest: NewDigest([]byte("after"))}, - PreviousAcceptanceID: "sha256:" + strings.Repeat("d", 64), - ToolBinary: definition.Software.ToolBinary, SourceCommit: definition.Software.SourceCommit, - GnarkVersion: GnarkVersion, GnarkCryptoVersion: GnarkCryptoVersion, DrandVersion: DrandVersion, - Environment: ContributionEnvironment{ - OS: "linux", Architecture: "arm64", EntropySource: "operating-system-csprng", - SwapDisabled: true, CrashDumpsDisabled: true, TelemetryDisabled: true, - EphemeralEnvironment: true, EphemeralDestructionRequired: true, - }, - ContributedAt: "2026-07-23T14:00:00Z", - }) - if err != nil { - t.Fatal(err) - } - attestationBytes, err := MarshalCanonical(contribution) - if err != nil { - t.Fatal(err) - } - attestation := write("phase2/contributions/0001/attestation.json", attestationBytes) - phaseID := "sha256:" + strings.Repeat("c", 64) - genesis := contribution.PreviousPayload - acceptedChain, err := NewChain(definition.CeremonyID, Phase2, phaseID, genesis) - if err != nil { - t.Fatal(err) - } - previousRecordID, err := GenesisRecordID(definition.CeremonyID, phaseID, genesis) - if err != nil { - t.Fatal(err) - } - chainRecord, err := NewChainRecord(ChainRecord{ - CeremonyID: definition.CeremonyID, - Phase: Phase2, - PhaseID: phaseID, - Index: 1, - ParticipantID: "participant-01", - PreviousPayload: contribution.PreviousPayload, - OutputPayload: contribution.OutputPayload, - AttestationID: contribution.AttestationID, - Attestation: attestation, - AttestationSignature: ArtifactRef{Name: "phase2/contributions/0001/attestation.sig", Digest: NewDigest([]byte("attestation signature"))}, - ErasureID: "sha256:" + strings.Repeat("e", 64), - Erasure: ArtifactRef{Name: "phase2/contributions/0001/erasure.json", Digest: NewDigest([]byte("erasure"))}, - ErasureSignature: ArtifactRef{Name: "phase2/contributions/0001/erasure.sig", Digest: NewDigest([]byte("erasure signature"))}, - Verification: ArtifactRef{Name: "phase2/contributions/0001/verification.json", Digest: NewDigest([]byte("verification"))}, - PreviousRecordID: previousRecordID, - CoordinatorID: definition.Coordinator.ID, - CoordinatorKeyID: definition.Coordinator.KeyID, - AcceptedAt: "2026-07-23T14:30:00Z", - }) - if err != nil { - t.Fatal(err) - } - if err := acceptedChain.Append(chainRecord); err != nil { - t.Fatal(err) - } - chainBytes, err := MarshalCanonical(acceptedChain) - if err != nil { - t.Fatal(err) - } - chain := write("phase2/chain.json", chainBytes) - emptyGenesis := ArtifactRef{Name: "phase1/genesis.bin", Digest: NewDigest([]byte("phase1 genesis"))} - emptyChainModel, err := NewChain( - definition.CeremonyID, - Phase1, - "sha256:"+strings.Repeat("f", 64), - emptyGenesis, - ) - if err != nil { - t.Fatal(err) - } - emptyChainBytes, err := MarshalCanonical(emptyChainModel) - if err != nil { - t.Fatal(err) - } - emptyChain := write("phase1/chain.json", emptyChainBytes) - wipeRecord := write("host-wipes/participant-01.json", recordBytes) - wipeSignature := write("host-wipes/participant-01.sig", signatureBytes) - bundle := OperationalEvidenceBundle{ - HostWipes: []SignedArtifactRefs{{Record: wipeRecord, Signature: wipeSignature}}, - Phase1: PhaseOperationalEvidence{AcceptedChain: SignedArtifactRefs{Record: emptyChain}}, - Phase2: PhaseOperationalEvidence{AcceptedChain: SignedArtifactRefs{Record: chain}}, - } - if _, err := verifyHostWipeEvidence(definition, root, bundle); err != nil { - t.Fatalf("valid release host-wipe gate: %v", err) - } - - missing := bundle - missing.HostWipes = nil - if _, err := verifyHostWipeEvidence(definition, root, missing); err == nil || - !strings.Contains(err.Error(), "want exactly 1") { - t.Fatalf("missing host wipe error = %v", err) - } - - tooEarly := record - tooEarly.WipedAt = "2026-07-23T13:00:00Z" - tooEarly.HostWipeID = "" - tooEarly, err = NewHostWipeAttestation(tooEarly) - if err != nil { - t.Fatal(err) - } - earlyBytes, earlySignature, err := SignRecord( - tooEarly, - definition.Roster[0].Identity.KeyID, - adversarialPrivateKey(0x11), - ) - if err != nil { - t.Fatal(err) - } - bundle.HostWipes[0] = SignedArtifactRefs{ - Record: write("host-wipes/too-early.json", earlyBytes), - Signature: write("host-wipes/too-early.sig", earlySignature), - } - if _, err := verifyHostWipeEvidence(definition, root, bundle); err == nil || - !strings.Contains(err.Error(), "does not postdate") { - t.Fatalf("early host wipe error = %v", err) - } -} - -func TestHostWipeDefinitionPolicyIsFrozenAndOrdered(t *testing.T) { - definition := adversarialDefinition(t) - definition.HostWipeParticipants = []string{"participant-02", "participant-01"} - if _, err := FinalizeCeremonyDefinition(definition); err == nil || - !strings.Contains(err.Error(), "must be sorted") { - t.Fatalf("unsorted host-wipe policy error = %v", err) - } - - definition.HostWipeParticipants = []string{"participant-99"} - if _, err := FinalizeCeremonyDefinition(definition); err == nil || - !strings.Contains(err.Error(), "not in the signed roster") { - t.Fatalf("unknown host-wipe participant error = %v", err) - } - - definition = adversarialDefinition(t) - definition.Mode = ModeRehearsal - definition.HostWipeParticipants = []string{"participant-01"} - if _, err := FinalizeCeremonyDefinition(definition); err == nil || - !strings.Contains(err.Error(), "rehearsal ceremony") { - t.Fatalf("rehearsal host-wipe policy error = %v", err) - } -} - -func TestHostWipeTimestampParsesNanoseconds(t *testing.T) { - value := time.Date(2026, 7, 23, 15, 0, 0, 123, time.UTC).Format(time.RFC3339Nano) - if err := (HostWipeAttestation{ - Schema: HostWipeAttestationSchema, HostWipeID: "sha256:" + strings.Repeat("a", 64), - CeremonyID: "sha256:" + strings.Repeat("b", 64), ParticipantID: "participant-01", - ParticipantKeyID: "participant-key", HostOS: "darwin", WholeDeviceErased: true, - OperatingSystemReinstalled: true, NoPreWipeSystemBackupOrSnapshotRestored: true, - NoDockerDesktopStateRestored: true, NoContributionRandomnessCopyRetained: true, WipedAt: value, - }).validate(true); err != nil { - t.Fatal(err) - } -} diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index cb2bc9e0..da406f78 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -22,8 +22,8 @@ const ( DefinitionSchemaV1 = "proof-tool-mpc-ceremony-definition-v1" DefinitionSchema = "proof-tool-mpc-ceremony-definition-v2" DetachedSignatureSchema = "proof-tool-mpc-detached-signature-v1" - ContributionAttestationSchema = "proof-tool-mpc-contribution-attestation-v1" - ErasureAttestationSchema = "proof-tool-mpc-erasure-attestation-v1" + ContributionAttestationSchema = "proof-tool-mpc-contribution-attestation-v2" + ErasureAttestationSchema = "proof-tool-mpc-erasure-attestation-v2" ChainSchema = "proof-tool-mpc-accepted-chain-v1" ChainRecordSchema = "proof-tool-mpc-acceptance-record-v1" CloseRecordSchema = "proof-tool-mpc-close-record-v1" diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index c43e2253..666de88c 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -35,13 +35,12 @@ const ( RecordMirrorReceipt OperationalRecordType = "mirror-receipt" RecordEvidenceBundle OperationalRecordType = "evidence-bundle" RecordGovernance OperationalRecordType = "governance" - RecordHostWipe OperationalRecordType = "host-wipe" ) func (t OperationalRecordType) Validate() error { switch t { case RecordEnrollment, RecordHandoff, RecordReceipt, RecordPublicWitness, - RecordBeaconEvidence, RecordMirrorReceipt, RecordEvidenceBundle, RecordGovernance, RecordHostWipe: + RecordBeaconEvidence, RecordMirrorReceipt, RecordEvidenceBundle, RecordGovernance: return nil default: return fmt.Errorf("unsupported operational record type %q", t) @@ -644,8 +643,6 @@ func ParseOperationalRecord(recordType OperationalRecordType, canonical []byte) destination = &OperationalEvidenceBundle{} case RecordGovernance: destination = &GovernanceRecord{} - case RecordHostWipe: - destination = &HostWipeAttestation{} default: return nil, fmt.Errorf("unsupported operational record type %q", recordType) } @@ -748,14 +745,6 @@ func VerifyOperationalRecordBinding( ceremonyID, signerID, signerKeyID = r.CeremonyID, r.CoordinatorID, r.CoordinatorKeyID case *GovernanceRecord: ceremonyID, signerID, signerKeyID = r.CeremonyID, r.SignerID, r.SignerKeyID - case *HostWipeAttestation: - ceremonyID, signerID, signerKeyID = r.CeremonyID, r.ParticipantID, r.ParticipantKeyID - if err := r.Validate(); err != nil { - return Identity{}, err - } - if !slices.Contains(definition.HostWipeParticipants, r.ParticipantID) { - return Identity{}, errors.New("host-wipe participant is not required by the signed ceremony") - } default: return Identity{}, fmt.Errorf("unsupported operational record %T", record) } diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index bf4aec36..b0f84e48 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -10,8 +10,7 @@ import ( ) const ( - OperationalEvidenceBundleSchemaV1 = "proof-tool-mpc-operational-evidence-bundle-v1" - OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v2" + OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v2" ) type SignedArtifactRefs struct { @@ -141,7 +140,6 @@ type OperationalEvidenceBundle struct { CeremonyID string `json:"ceremony_id"` Enrollments []SignedArtifactRefs `json:"enrollments"` GovernanceRecords []SignedArtifactRefs `json:"governance_records"` - HostWipes []SignedArtifactRefs `json:"host_wipes,omitempty"` Phase1 PhaseOperationalEvidence `json:"phase1"` Phase2 PhaseOperationalEvidence `json:"phase2"` CoordinatorID string `json:"coordinator_id"` @@ -150,7 +148,7 @@ type OperationalEvidenceBundle struct { } func (b OperationalEvidenceBundle) Validate() error { - if b.Schema != OperationalEvidenceBundleSchemaV1 && b.Schema != OperationalEvidenceBundleSchema { + if b.Schema != OperationalEvidenceBundleSchema { return fmt.Errorf("operational evidence schema %q is unsupported", b.Schema) } if err := validateHashID("ceremony_id", b.CeremonyID); err != nil { @@ -171,17 +169,6 @@ func (b OperationalEvidenceBundle) Validate() error { return err } } - if b.Schema == OperationalEvidenceBundleSchemaV1 && len(b.HostWipes) != 0 { - return errors.New("operational evidence v1 must not contain host wipes") - } - if len(b.HostWipes) > MaxParticipants { - return fmt.Errorf("host_wipes exceeds maximum %d", MaxParticipants) - } - if len(b.HostWipes) > 0 { - if err := validateSignedArtifactSet("host_wipes", b.HostWipes); err != nil { - return err - } - } if err := b.Phase1.Validate(); err != nil { return fmt.Errorf("phase1: %w", err) } @@ -254,8 +241,8 @@ type VerifiedOperationalEvidence struct { // VerifyOperationalEvidenceBundle fail-closes across the signed bundle, // authenticated close records, witness signatures/quorum/timing, every raw -// relay response, the pinned drand verification policy, and every post-wipe -// Mac attestation required by the signed ceremony definition. +// relay response, the pinned drand verification policy, and contribution-bound +// signed cleanup claims. These claims do not establish physical erasure. func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) (VerifiedOperationalEvidence, error) { if err := options.Definition.Validate(); err != nil { return VerifiedOperationalEvidence{}, err @@ -326,10 +313,6 @@ func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) ( if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err) } - hostWipeRefs, err := verifyHostWipeEvidence(options.Definition, options.EvidenceRoot, bundle) - if err != nil { - return VerifiedOperationalEvidence{}, fmt.Errorf("host-wipe evidence: %w", err) - } latest, err := latestOperationalTimestamp(options.EvidenceRoot, bundle) if err != nil { return VerifiedOperationalEvidence{}, err @@ -345,7 +328,6 @@ func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) ( all := append(enrollmentRefs, governanceRefs...) all = append(all, phase1Refs...) all = append(all, phase2Refs...) - all = append(all, hostWipeRefs...) slices.SortFunc(all, func(a, b ArtifactRef) int { if a.Name < b.Name { return -1 @@ -406,17 +388,6 @@ func latestOperationalTimestamp(root string, bundle OperationalEvidenceBundle) ( } advance(record.RecordedAt) } - for _, pair := range bundle.HostWipes { - raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) - if err != nil { - return time.Time{}, err - } - var record HostWipeAttestation - if err := UnmarshalCanonical(raw, &record); err != nil { - return time.Time{}, err - } - advance(record.WipedAt) - } for _, phase := range []PhaseOperationalEvidence{bundle.Phase1, bundle.Phase2} { chainBytes, err := verifyArtifactBytes(root, phase.AcceptedChain.Record, maxSignedRecordBytes) if err != nil { @@ -503,83 +474,6 @@ func latestOperationalTimestamp(root string, bundle OperationalEvidenceBundle) ( return latest, nil } -func verifyHostWipeEvidence( - definition CeremonyDefinition, - root string, - bundle OperationalEvidenceBundle, -) ([]ArtifactRef, error) { - required := definition.HostWipeParticipants - if len(bundle.HostWipes) != len(required) { - return nil, fmt.Errorf("got %d host-wipe attestations, want exactly %d", len(bundle.HostWipes), len(required)) - } - if len(required) == 0 { - return nil, nil - } - latestContribution := make(map[string]time.Time, len(required)) - for _, phase := range []PhaseOperationalEvidence{bundle.Phase1, bundle.Phase2} { - chainBytes, err := verifyArtifactBytes(root, phase.AcceptedChain.Record, maxSignedRecordBytes) - if err != nil { - return nil, err - } - var chain Chain - if err := UnmarshalCanonical(chainBytes, &chain); err != nil { - return nil, err - } - for _, record := range chain.Records { - if !slices.Contains(required, record.ParticipantID) { - continue - } - attestationBytes, err := verifyArtifactBytes(root, record.Attestation, maxSignedRecordBytes) - if err != nil { - return nil, err - } - var attestation ContributionAttestation - if err := UnmarshalCanonical(attestationBytes, &attestation); err != nil { - return nil, err - } - contributed, _ := time.Parse(time.RFC3339Nano, attestation.ContributedAt) - if contributed.After(latestContribution[record.ParticipantID]) { - latestContribution[record.ParticipantID] = contributed - } - } - } - seen := make(map[string]struct{}, len(required)) - refs := make([]ArtifactRef, 0, len(bundle.HostWipes)*2) - for index, pair := range bundle.HostWipes { - recordBytes, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) - if err != nil { - return nil, fmt.Errorf("host wipe %d record: %w", index, err) - } - signatureBytes, err := verifyArtifactBytes(root, pair.Signature, maxSignedRecordBytes) - if err != nil { - return nil, fmt.Errorf("host wipe %d signature: %w", index, err) - } - record, err := VerifyHostWipeAttestation(definition, recordBytes, signatureBytes) - if err != nil { - return nil, fmt.Errorf("host wipe %d: %w", index, err) - } - if _, duplicate := seen[record.ParticipantID]; duplicate { - return nil, fmt.Errorf("host wipe for participant %q is duplicated", record.ParticipantID) - } - contributed, ok := latestContribution[record.ParticipantID] - if !ok { - return nil, fmt.Errorf("host-wipe participant %q has no accepted contribution", record.ParticipantID) - } - wiped, _ := time.Parse(time.RFC3339Nano, record.WipedAt) - if !wiped.After(contributed) { - return nil, fmt.Errorf("host wipe for participant %q does not postdate their final contribution", record.ParticipantID) - } - seen[record.ParticipantID] = struct{}{} - refs = append(refs, pair.Record, pair.Signature) - } - for _, participantID := range required { - if _, ok := seen[participantID]; !ok { - return nil, fmt.Errorf("required host wipe for participant %q is missing", participantID) - } - } - return refs, nil -} - func verifyPhaseOperationalEvidence( definition CeremonyDefinition, coordinatorPublicKey ed25519.PublicKey, diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 46ccec41..7573d0f9 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -43,15 +43,6 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Fatalf("complete operational bundle rejected: %v", err) } - t.Run("legacy v1 bundle without host-wipe policy", func(t *testing.T) { - f := newOperationalBundleFixture(t) - f.bundle.Schema = OperationalEvidenceBundleSchemaV1 - resignBundle(t, &f) - if err := verify(f); err != nil { - t.Fatalf("legacy operational bundle rejected: %v", err) - } - }) - t.Run("missing enrollment", func(t *testing.T) { f := newOperationalBundleFixture(t) f.bundle.Enrollments = f.bundle.Enrollments[1:] @@ -591,14 +582,15 @@ func buildOperationalPhaseFixture( GnarkCryptoVersion: GnarkCryptoVersion, DrandVersion: DrandVersion, Environment: ContributionEnvironment{ - OS: "linux", - Architecture: "amd64", - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, + OS: "linux", + Architecture: "amd64", + EntropySource: "operating-system-csprng", + ContributorSwapDisabled: true, + ContributorCrashDumpsDisabled: true, + ContributorTelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralCleanupRequired: true, + HostRemnantsNotExcluded: true, }, ContributedAt: roundTime.Add(-27 * time.Hour).Format(time.RFC3339), }) @@ -610,18 +602,19 @@ func buildOperationalPhaseFixture( definition.Roster[0].Identity.KeyID, adversarialPrivateKey(0x11), ) erasure, err := NewErasureAttestation(ErasureAttestation{ - CeremonyID: definition.CeremonyID, - Phase: phase, - PhaseID: phaseID, - Index: 1, - ParticipantID: definition.Roster[0].Identity.ID, - ParticipantKeyID: definition.Roster[0].Identity.KeyID, - ContributionAttestationID: attestation.AttestationID, - OutputPayload: outputPayload, - DestroyedAt: roundTime.Add(-26*time.Hour - 30*time.Minute).Format(time.RFC3339), - ProcessTerminated: true, - EphemeralStorageDestroyed: true, - NoBackupRetained: true, + CeremonyID: definition.CeremonyID, + Phase: phase, + PhaseID: phaseID, + Index: 1, + ParticipantID: definition.Roster[0].Identity.ID, + ParticipantKeyID: definition.Roster[0].Identity.KeyID, + ContributionAttestationID: attestation.AttestationID, + OutputPayload: outputPayload, + DestroyedAt: roundTime.Add(-26*time.Hour - 30*time.Minute).Format(time.RFC3339), + ProcessTerminated: true, + EphemeralEnvironmentRemoved: true, + NoDeliberateCopiesConfirmed: true, + HostRemnantsNotExcluded: true, }) if err != nil { t.Fatal(err) diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 84367af1..6e4f0ed1 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -306,14 +306,15 @@ func run(outputRoot, operationalEvidenceHelper string) error { }, nil } environment := mpcceremony.ContributionEnvironment{ - OS: "linux", - Architecture: "amd64", - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, + OS: "linux", + Architecture: "amd64", + EntropySource: "operating-system-csprng", + ContributorSwapDisabled: true, + ContributorCrashDumpsDisabled: true, + ContributorTelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralCleanupRequired: true, + HostRemnantsNotExcluded: true, } participantKeyPaths := []string{participant1KeyPath, participant2KeyPath} contributeAndAccept := func( diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index aa654a59..ff2d4bdc 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -60,11 +60,10 @@ type TrustedCeremony struct { // InitParticipants is the fixed-field, canonical enrollment input accepted by // the coordinator init command. It contains public signing identities only. type InitParticipants struct { - Coordinator Identity `json:"coordinator"` - ReleaseSigner Identity `json:"release_signer"` - Auditors []Identity `json:"auditors"` - Roster []Participant `json:"roster"` - HostWipeParticipants []string `json:"host_wipe_participants,omitempty"` + Coordinator Identity `json:"coordinator"` + ReleaseSigner Identity `json:"release_signer"` + Auditors []Identity `json:"auditors"` + Roster []Participant `json:"roster"` } func (p InitParticipants) Validate() error { @@ -87,7 +86,6 @@ func (p InitParticipants) Validate() error { identityIDs := make(map[string]string, 2+len(p.Auditors)+len(p.Roster)) keyIDs := make(map[string]string, 2+len(p.Auditors)+len(p.Roster)) publicKeyFingerprints := make(map[string]string, 2+len(p.Auditors)+len(p.Roster)) - rosterIDs := make(map[string]struct{}, len(p.Roster)) add := func(identity Identity, role string) error { if previous, exists := identityIDs[identity.ID]; exists { return fmt.Errorf("%s identity %q duplicates %s", role, identity.ID, previous) @@ -124,18 +122,6 @@ func (p InitParticipants) Validate() error { if err := add(participant.Identity, "participant"); err != nil { return err } - rosterIDs[participant.Identity.ID] = struct{}{} - } - if !slices.IsSorted(p.HostWipeParticipants) { - return errors.New("host_wipe_participants must be sorted") - } - for index, id := range p.HostWipeParticipants { - if index > 0 && id == p.HostWipeParticipants[index-1] { - return errors.New("host_wipe_participants must not contain duplicates") - } - if _, ok := rosterIDs[id]; !ok { - return fmt.Errorf("host-wipe participant %q is not in the roster", id) - } } return nil } @@ -891,18 +877,19 @@ func CreateErasureAttestationFiles( return result, errors.New("contribution attestation does not match participant or ceremony") } erasure, err := NewErasureAttestation(ErasureAttestation{ - CeremonyID: attestation.CeremonyID, - Phase: attestation.Phase, - PhaseID: attestation.PhaseID, - Index: attestation.Index, - ParticipantID: attestation.ParticipantID, - ParticipantKeyID: attestation.ParticipantKeyID, - ContributionAttestationID: attestation.AttestationID, - OutputPayload: attestation.OutputPayload, - DestroyedAt: options.DestroyedAt, - ProcessTerminated: true, - EphemeralStorageDestroyed: true, - NoBackupRetained: true, + CeremonyID: attestation.CeremonyID, + Phase: attestation.Phase, + PhaseID: attestation.PhaseID, + Index: attestation.Index, + ParticipantID: attestation.ParticipantID, + ParticipantKeyID: attestation.ParticipantKeyID, + ContributionAttestationID: attestation.AttestationID, + OutputPayload: attestation.OutputPayload, + DestroyedAt: options.DestroyedAt, + ProcessTerminated: true, + EphemeralEnvironmentRemoved: true, + NoDeliberateCopiesConfirmed: true, + HostRemnantsNotExcluded: true, }) if err != nil { return result, err diff --git a/internal/mpcceremony/workflow_test.go b/internal/mpcceremony/workflow_test.go index e7283e8b..78f1fecd 100644 --- a/internal/mpcceremony/workflow_test.go +++ b/internal/mpcceremony/workflow_test.go @@ -96,14 +96,15 @@ func TestReadRegularBoundedRejectsSymlinkLeaf(t *testing.T) { func TestCanonicalInitInputsRejectUnknownAndNonCanonicalJSON(t *testing.T) { path := filepath.Join(t.TempDir(), "environment.json") valid := ContributionEnvironment{ - OS: "linux", - Architecture: "amd64", - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, + OS: "linux", + Architecture: "amd64", + EntropySource: "operating-system-csprng", + ContributorSwapDisabled: true, + ContributorCrashDumpsDisabled: true, + ContributorTelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralCleanupRequired: true, + HostRemnantsNotExcluded: true, } data, err := MarshalCanonical(valid) if err != nil { diff --git a/internal/mpcrehearsal/config.go b/internal/mpcrehearsal/config.go index dfe87546..6566879c 100644 --- a/internal/mpcrehearsal/config.go +++ b/internal/mpcrehearsal/config.go @@ -181,14 +181,15 @@ func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (er }, } environment := mpcceremony.ContributionEnvironment{ - OS: runtime.GOOS, - Architecture: runtime.GOARCH, - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + EntropySource: "operating-system-csprng", + ContributorSwapDisabled: true, + ContributorCrashDumpsDisabled: true, + ContributorTelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralCleanupRequired: true, + HostRemnantsNotExcluded: true, } for name, value := range map[string]any{ "participants.json": enrollment, From 6fabdf49ef1e4f8acb9025e574a3740448f15b1c Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Tue, 8 Sep 2026 10:48:23 +0900 Subject: [PATCH 50/64] fix: verify authenticated tiny rehearsal release bundles (#19) * feat: replace host wipe gate with scoped cleanup claims * fix: verify authenticated tiny rehearsal release bundles --- docs/mpc-ceremony-release.md | 7 ++ internal/keybundle/keybundle.go | 30 ++++++- internal/keybundle/keybundle_test.go | 114 +++++++++++++++++++++++++++ internal/keyprofile/profile_test.go | 3 + internal/mpcceremony/audit.go | 8 +- internal/prover/prover.go | 11 +++ 6 files changed, 168 insertions(+), 5 deletions(-) diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index e9684acb..67051bf5 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -47,6 +47,13 @@ contribution binding, timing, audits, witnesses, mirrors, and beacon evidence. Use matching new Relay/proof-tool binaries and regenerate environment inputs; old attestation schemas and removed wipe fields are rejected. +Tiny rehearsals can complete final release signing and verification through +`mpc-ceremony`. The tiny profile is selected only after authenticating a +rehearsal-mode ceremony definition. Ordinary application key-bundle verification +still rejects it; it is not registered as a production prover/verifier profile. +Native file hashes, release signatures, audits and operational evidence remain +mandatory. A tiny release cannot satisfy the production K=21 decision gate. + ## Coordinated distribution Compatibility with Relay is tested after both projects have released diff --git a/internal/keybundle/keybundle.go b/internal/keybundle/keybundle.go index eef8ea8c..d72bb2a9 100644 --- a/internal/keybundle/keybundle.go +++ b/internal/keybundle/keybundle.go @@ -16,7 +16,9 @@ import ( "strings" "proof-tool/internal/artifact" + "proof-tool/internal/circuit/rehearsal" "proof-tool/internal/keyprofile" + "proof-tool/internal/prover" "proof-tool/internal/strictjson" ) @@ -43,6 +45,21 @@ type VerifyOptions struct { // Verify checks the supported circuit profile, native PK/VK file pins, // signature-key identity, and Ed25519 signature over the exact manifest bytes. func Verify(opts VerifyOptions) (*artifact.KeyManifest, error) { + return verify(opts, false) +} + +// VerifyRehearsal verifies the exact tiny rehearsal profile, retaining all +// signature and native-file pin checks. Only the ceremony verifier should call +// it, after authenticating a rehearsal-mode definition. Production callers use +// Verify, which continues to reject this key version even if its signature is valid. +func VerifyRehearsal(opts VerifyOptions) (*artifact.KeyManifest, error) { + if opts.KeyVersion != rehearsal.KeyVersion { + return nil, errors.New("explicit tiny rehearsal key version is required") + } + return verify(opts, true) +} + +func verify(opts VerifyOptions, tinyRehearsal bool) (*artifact.KeyManifest, error) { if strings.TrimSpace(opts.PublicKeyHex) == "" { return nil, errors.New("trusted manifest public key is required") } @@ -69,11 +86,16 @@ func Verify(opts VerifyOptions) (*artifact.KeyManifest, error) { if strings.TrimSpace(keyVersion) == "" { keyVersion = signedManifest.KeyVersion } - profile, err := keyprofile.ForKeyVersion(keyVersion) - if err != nil { - return nil, err + var status prover.BundleStatus + if tinyRehearsal { + status = prover.InspectRehearsalBundle(opts.KeysDir, opts.RequireProvingKey) + } else { + profile, err := keyprofile.ForKeyVersion(keyVersion) + if err != nil { + return nil, err + } + status = profile.Inspect(opts.KeysDir, opts.RequireProvingKey) } - status := profile.Inspect(opts.KeysDir, opts.RequireProvingKey) if !status.Ready { return nil, fmt.Errorf("key bundle is not ready: %s", status.Error) } diff --git a/internal/keybundle/keybundle_test.go b/internal/keybundle/keybundle_test.go index a2f28b13..79d0d011 100644 --- a/internal/keybundle/keybundle_test.go +++ b/internal/keybundle/keybundle_test.go @@ -3,6 +3,7 @@ package keybundle import ( "crypto/ed25519" "encoding/hex" + "encoding/json" "os" "path/filepath" "runtime" @@ -10,6 +11,8 @@ import ( "testing" "proof-tool/internal/artifact" + "proof-tool/internal/circuit/rehearsal" + "proof-tool/internal/prover" ) func TestLoadExistingPrivateKeyDoesNotGenerate(t *testing.T) { @@ -125,3 +128,114 @@ func TestRequireManifestMatchRejectsInspectedManifestMismatch(t *testing.T) { t.Fatalf("manifest mismatch error = %v", err) } } + +// These fixtures exercise signature/profile/file-pin verification, not Groth16 +// deserialization. The ceremony lifecycle test supplies real generated keys. +func rehearsalBundleFixture(t *testing.T) (VerifyOptions, func(func(*artifact.KeyManifest))) { + t.Helper() + dir := t.TempDir() + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + for _, name := range []string{"ownership.pk", "ownership.vk"} { + if err := os.WriteFile(filepath.Join(dir, name), []byte("test file pins: "+name), 0o600); err != nil { + t.Fatal(err) + } + } + pk, err := prover.DigestFile(filepath.Join(dir, "ownership.pk")) + if err != nil { + t.Fatal(err) + } + vk, err := prover.DigestFile(filepath.Join(dir, "ownership.vk")) + if err != nil { + t.Fatal(err) + } + manifest := artifact.KeyManifest{ + Schema: artifact.ManifestSchema, KeyVersion: rehearsal.KeyVersion, + CircuitID: rehearsal.CircuitID, Curve: "BLS12-381", Backend: "groth16", + ProvingKeySHA256: pk.SHA256, ProvingKeyBlake2b256: pk.Blake2b256, ProvingKeySize: pk.Size, + VKHash: vk.Blake2b256, VerifyingKeySHA256: vk.SHA256, VerifyingKeySize: vk.Size, + SignatureKeyID: "test-rehearsal-signer", + } + writeSigned := func(change func(*artifact.KeyManifest)) { + t.Helper() + updated := manifest + if change != nil { + change(&updated) + } + raw, err := json.Marshal(updated) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, ManifestFile), raw, 0o600); err != nil { + t.Fatal(err) + } + sig := hex.EncodeToString(ed25519.Sign(privateKey, raw)) + if err := os.WriteFile(filepath.Join(dir, ManifestSignatureFile), []byte(sig), 0o600); err != nil { + t.Fatal(err) + } + } + writeSigned(nil) + return VerifyOptions{ + KeysDir: dir, KeyVersion: rehearsal.KeyVersion, PublicKeyHex: hex.EncodeToString(publicKey), + ExpectedSignatureKeyID: manifest.SignatureKeyID, RequireProvingKey: true, + }, writeSigned +} + +func TestVerifyRehearsalDoesNotBroadenProductionProfiles(t *testing.T) { + opts, _ := rehearsalBundleFixture(t) + if _, err := VerifyRehearsal(opts); err != nil { + t.Fatalf("explicit rehearsal verification: %v", err) + } + if _, err := Verify(opts); err == nil || !strings.Contains(err.Error(), "unsupported key version") { + t.Fatalf("production verifier accepted rehearsal profile: %v", err) + } + opts.KeyVersion = "" + if _, err := Verify(opts); err == nil { + t.Fatal("production verifier inferred and accepted rehearsal profile") + } + if _, err := VerifyRehearsal(opts); err == nil { + t.Fatal("rehearsal verifier accepted an implicit profile") + } + opts.KeyVersion = "ownership-destination-v2" + if _, err := VerifyRehearsal(opts); err == nil { + t.Fatal("rehearsal verifier accepted a production profile") + } +} + +func TestVerifyRehearsalRetainsSignatureAndFilePinChecks(t *testing.T) { + for _, name := range []string{"ownership.pk", "ownership.vk", ManifestSignatureFile} { + t.Run(name, func(t *testing.T) { + opts, _ := rehearsalBundleFixture(t) + if err := os.WriteFile(filepath.Join(opts.KeysDir, name), []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := VerifyRehearsal(opts); err == nil { + t.Fatal("changed file was accepted") + } + }) + } + for name, change := range map[string]func(*artifact.KeyManifest){ + "key version": func(m *artifact.KeyManifest) { m.KeyVersion = "ownership-destination-v2" }, + "circuit": func(m *artifact.KeyManifest) { m.CircuitID = "wrong-circuit" }, + "curve": func(m *artifact.KeyManifest) { m.Curve = "wrong-curve" }, + "backend": func(m *artifact.KeyManifest) { m.Backend = "wrong-backend" }, + "signer ID": func(m *artifact.KeyManifest) { m.SignatureKeyID = "wrong-signer" }, + } { + t.Run(name, func(t *testing.T) { + opts, writeSigned := rehearsalBundleFixture(t) + writeSigned(change) + if _, err := VerifyRehearsal(opts); err == nil { + t.Fatal("incorrect signed profile was accepted") + } + }) + } + t.Run("trust anchor", func(t *testing.T) { + opts, _ := rehearsalBundleFixture(t) + opts.PublicKeyHex = "" + if _, err := VerifyRehearsal(opts); err == nil { + t.Fatal("missing trust anchor was accepted") + } + }) +} diff --git a/internal/keyprofile/profile_test.go b/internal/keyprofile/profile_test.go index cc1f5763..0ed6d613 100644 --- a/internal/keyprofile/profile_test.go +++ b/internal/keyprofile/profile_test.go @@ -32,4 +32,7 @@ func TestForKeyVersion(t *testing.T) { if _, err := ForKeyVersion("ownership-destination-v1"); err == nil || !strings.Contains(err.Error(), "unsupported key version") { t.Fatalf("legacy key version err = %v", err) } + if _, err := ForKeyVersion("rehearsal-tiny-v1"); err == nil { + t.Fatal("tiny rehearsal registered as a production key profile") + } } diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index aceb6a86..01223bcb 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -534,7 +534,13 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { if err := requireIdentityKey(definition.ReleaseSigner, trustedKey); err != nil { return nil, fmt.Errorf("trusted release public key: %w", err) } - manifest, err := keybundle.Verify(keybundle.VerifyOptions{ + verifyBundle := keybundle.Verify + // This choice comes only from the already authenticated, validated ceremony, + // never from the release manifest or a caller-controlled verification flag. + if definition.Mode == ModeRehearsal && definition.Circuit.KeyVersion == KeyVersionRehearsal { + verifyBundle = keybundle.VerifyRehearsal + } + manifest, err := verifyBundle(keybundle.VerifyOptions{ KeysDir: options.KeysDir, KeyVersion: definition.Circuit.KeyVersion, PublicKeyHex: options.TrustedPublicKeyHex, diff --git a/internal/prover/prover.go b/internal/prover/prover.go index 1b33a51d..89e7875b 100644 --- a/internal/prover/prover.go +++ b/internal/prover/prover.go @@ -27,6 +27,7 @@ import ( "proof-tool/internal/circuit/ownership" "proof-tool/internal/circuit/ownershipdest" "proof-tool/internal/circuit/ownershipmulti" + "proof-tool/internal/circuit/rehearsal" ) var curve = ecc.BLS12_381 @@ -379,6 +380,16 @@ func InspectOwnershipDestinationBundle(dir string, requireProvingKey bool) Bundl return inspectBundle(dir, requireProvingKey, ownershipDestinationKeyConfig()) } +// InspectRehearsalBundle is only for ceremony rehearsal release verification. +// It is deliberately absent from the production keyprofile registry and does +// not provide a production prover/verifier loader for the trivial circuit. +func InspectRehearsalBundle(dir string, requireProvingKey bool) BundleStatus { + if dir == "" { + return BundleStatus{State: "invalid", Error: "explicit rehearsal keys directory is required"} + } + return inspectBundle(dir, requireProvingKey, keyConfig{KeyVersion: rehearsal.KeyVersion, CircuitID: rehearsal.CircuitID}) +} + func InspectOwnershipMultiBundle(dir string, requireProvingKey bool) BundleStatus { return InspectOwnershipMultiBundleForCount(dir, requireProvingKey, ownershipmulti.DefaultCredentialCount) } From 0d6d095d13d12d5495ace3dc968da1f73b29b995 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Tue, 8 Sep 2026 16:10:49 +0900 Subject: [PATCH 51/64] feat: prepare and sign reviewed operational records (#20) --- cmd/mpc-ceremony/executor.go | 4 + cmd/mpc-ceremony/ops_guided.go | 187 ++++++++++++++++++++ cmd/mpc-ceremony/ops_guided_test.go | 173 ++++++++++++++++++ cmd/mpc-ceremony/parse.go | 8 + cmd/mpc-ceremony/public_witness_ops_test.go | 12 +- cmd/mpc-ceremony/types.go | 2 + cmd/mpc-ceremony/usage.go | 25 +++ docs/mpc-ceremony-release.md | 13 ++ internal/mpcceremony/enrollment_prepare.go | 38 ++++ 9 files changed, 461 insertions(+), 1 deletion(-) create mode 100644 cmd/mpc-ceremony/ops_guided.go create mode 100644 cmd/mpc-ceremony/ops_guided_test.go create mode 100644 internal/mpcceremony/enrollment_prepare.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 4f54e8d6..a68e8aca 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -77,6 +77,10 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeOpsPrepareMirrorReceipt(invocation.Options.(OpsPrepareMirrorReceiptOptions)) case CommandOpsExportSigning: return executeOpsExportSigning(invocation.Options.(OpsExportSigningOptions)) + case CommandOpsPrepareEnrollment: + return executeOpsPrepareEnrollment(invocation.Options.(OpsPrepareEnrollmentOptions)) + case CommandOpsSign: + return executeOpsSign(invocation.Options.(OpsSignOptions)) case CommandOpsImportSig: return executeOpsImportSignature(invocation.Options.(OpsImportSignatureOptions)) case CommandOpsVerify: diff --git a/cmd/mpc-ceremony/ops_guided.go b/cmd/mpc-ceremony/ops_guided.go new file mode 100644 index 00000000..c910466a --- /dev/null +++ b/cmd/mpc-ceremony/ops_guided.go @@ -0,0 +1,187 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + "proof-tool/internal/keybundle" + "proof-tool/internal/mpcceremony" +) + +type OpsPrepareEnrollmentOptions struct { + CeremonyPath, CeremonySignaturePath, CoordinatorPublicKeyFile string + IdentityPath, Role, DisclosurePath, EnrolledAt, OutDir string + RoleIndex uint +} +type OpsSignOptions struct { + OpsExportSigningOptions + SigningKey, OutPath string + ReviewedSHA256 string + Reviewed bool +} + +func parseOpsPrepareEnrollment(args []string) (OpsPrepareEnrollmentOptions, error) { + var o OpsPrepareEnrollmentOptions + f := commandFlagSet("ops prepare-enrollment") + addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + f.StringVar(&o.IdentityPath, "identity", "", "owner's public identity JSON") + f.StringVar(&o.Role, "role", "", "enrollment role") + f.UintVar(&o.RoleIndex, "role-index", 1, "coordinator-assigned one-based index for external witnesses/mirrors") + f.StringVar(&o.DisclosurePath, "disclosure", "", "owner-authored public independence disclosure text file") + f.StringVar(&o.EnrolledAt, "enrolled-at", "", "actual enrollment timestamp") + f.StringVar(&o.OutDir, "out-dir", "", "fresh public enrollment directory") + if err := parseFlags(f, args); err != nil { + return o, err + } + if o.RoleIndex < 1 || o.RoleIndex > 65535 { + return o, errors.New("role index must be between 1 and 65535") + } + return o, requireValues(pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--identity", o.IdentityPath), value("--role", o.Role), pathValue("--disclosure", o.DisclosurePath), value("--enrolled-at", o.EnrolledAt), pathValue("--out-dir", o.OutDir)) +} +func parseOpsSign(args []string) (OpsSignOptions, error) { + var o OpsSignOptions + f := commandFlagSet("ops sign") + addOpsRecordFlags(f, &o.RecordType, &o.RecordPath) + addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + f.StringVar(&o.SigningKey, "signing-key", "", "owner's existing private key file") + f.StringVar(&o.OutPath, "out", "", "fresh detached signature JSON") + f.BoolVar(&o.Reviewed, "reviewed", false, "owner reviewed exact record and confirms its claims") + f.StringVar(&o.ReviewedSHA256, "reviewed-sha256", "", "optional SHA-256 of the exact bytes shown during interactive review") + if err := parseFlags(f, args); err != nil { + return o, err + } + return o, requireValues(value("--record-type", o.RecordType), pathValue("--record", o.RecordPath), pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--signing-key", o.SigningKey), pathValue("--out", o.OutPath)) +} +func executeOpsPrepareEnrollment(o OpsPrepareEnrollmentOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: o.CeremonyPath, DefinitionSignaturePath: o.CeremonySignaturePath, CoordinatorPublicKeyPath: o.CoordinatorPublicKeyFile}) + if err != nil { + return CommandResult{}, err + } + raw, err := readRegularOperationalFile(o.IdentityPath, 16384) + if err != nil { + return CommandResult{}, err + } + var id mpcceremony.Identity + d := json.NewDecoder(bytes.NewReader(raw)) + d.DisallowUnknownFields() + if err := d.Decode(&id); err != nil { + return CommandResult{}, err + } + canonicalID, err := json.Marshal(id) + if err != nil { + return CommandResult{}, err + } + var compact bytes.Buffer + if err := json.Compact(&compact, raw); err != nil || !bytes.Equal(compact.Bytes(), canonicalID) { + return CommandResult{}, errors.New("use the original canonical public identity JSON") + } + if err := id.Validate(); err != nil { + return CommandResult{}, err + } + disclosure, err := readRegularOperationalFile(o.DisclosurePath, 65536) + if err != nil { + return CommandResult{}, err + } + if !utf8.Valid(disclosure) || strings.TrimSpace(string(disclosure)) == "" { + return CommandResult{}, errors.New("public disclosure must be nonempty UTF-8 text") + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + ref := mpcceremony.ArtifactRef{Name: "enrollments/" + id.ID + "/disclosure.txt", Digest: mpcceremony.NewDigest(disclosure)} + r, err := mpcceremony.PrepareEnrollment(trusted.Definition, definitionBytes, id, mpcceremony.EnrollmentRole(o.Role), uint16(o.RoleIndex), ref, o.EnrolledAt) + if err != nil { + return CommandResult{}, err + } + canonical, err := mpcceremony.MarshalCanonical(r) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordEnrollment, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + path, requestPath, err := writeOperationalSigningExport(o.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + disclosurePath := filepath.Join(o.OutDir, filepath.FromSlash(ref.Name)) + if err := os.MkdirAll(filepath.Dir(disclosurePath), 0700); err != nil { + return CommandResult{}, err + } + if err := writeFreshOperationalFile(disclosurePath, disclosure, 0600); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Summary: "prepared unsigned enrollment; owner review and signature are still required, independence is not verified", Outputs: map[string]string{"canonical": path, "signing_request": requestPath, "disclosure": disclosurePath}}, nil +} +func executeOpsSign(o OpsSignOptions) (CommandResult, error) { + if !o.Reviewed { + return CommandResult{}, errors.New("owner must review the exact record and explicitly supply --reviewed") + } + kind := mpcceremony.OperationalRecordType(o.RecordType) + if kind != mpcceremony.RecordEnrollment && kind != mpcceremony.RecordPublicWitness && kind != mpcceremony.RecordMirrorReceipt { + return CommandResult{}, errors.New("ops sign is restricted to enrollment, public-witness and mirror-receipt records") + } + canonical, record, trusted, err := loadBoundOperationalRecord(kind, o.RecordPath, o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile) + if err != nil { + return CommandResult{}, err + } + if o.ReviewedSHA256 != "" && o.ReviewedSHA256 != fmt.Sprintf("%x", sha256.Sum256(canonical)) { + return CommandResult{}, errors.New("record changed since owner review") + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + owner, err := mpcceremony.VerifyOperationalRecordBinding(trusted.Definition, definitionBytes, record) + if err != nil { + return CommandResult{}, err + } + if enrollment, ok := record.(*mpcceremony.EnrollmentRecord); ok { + // The public export carries its disclosure tree. Never sign a disclosure + // hash whose accompanying bytes are missing or have changed. + disclosure, err := readRegularOperationalFile(filepath.Join(filepath.Dir(o.RecordPath), filepath.FromSlash(enrollment.IndependenceDisclosure.Name)), 65536) + if err != nil { + return CommandResult{}, err + } + if mpcceremony.NewDigest(disclosure) != enrollment.IndependenceDisclosure.Digest { + return CommandResult{}, errors.New("enrollment disclosure does not match the reviewed record") + } + } + if _, err := os.Lstat(o.OutPath); !errors.Is(err, os.ErrNotExist) { + return CommandResult{}, errors.New("signature output already exists or cannot be inspected") + } + key, public, err := keybundle.LoadExistingPrivateKey(o.SigningKey) + if err != nil { + return CommandResult{}, err + } + if hex.EncodeToString(public) != owner.Ed25519PublicKeyHex { + return CommandResult{}, errors.New("signing key does not belong to the record owner") + } + sig, err := mpcceremony.ImportOperationalSignature(canonical, owner.KeyID, public, ed25519.Sign(key, canonical)) + if err != nil { + return CommandResult{}, err + } + encoded, err := mpcceremony.MarshalCanonical(sig) + if err != nil { + return CommandResult{}, err + } + if err := writeFreshOperationalFile(o.OutPath, encoded, 0600); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Summary: fmt.Sprintf("signed reviewed %s claim as %s; this authenticates the owner, not physical independence or an observation by this program", kind, owner.ID), Outputs: map[string]string{"record": o.RecordPath, "signature": o.OutPath}}, nil +} diff --git a/cmd/mpc-ceremony/ops_guided_test.go b/cmd/mpc-ceremony/ops_guided_test.go new file mode 100644 index 00000000..e9f3280c --- /dev/null +++ b/cmd/mpc-ceremony/ops_guided_test.go @@ -0,0 +1,173 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "proof-tool/internal/mpcceremony" +) + +func TestGuidedMirrorReceiptSigning(t *testing.T) { + root := t.TempDir() + definition, _, coordinator := decisionSignFixture(t) + trust := writeInspectionTrustFixture(t, root, definition, coordinator) + enrollment, _, _, key := commandSignedExternalEnrollment(t, definition, mpcceremony.EnrollmentMirrorOperator, "mirror-owner", 0x92) + record := mpcceremony.ImmutableMirrorReceipt{Schema: mpcceremony.ImmutableMirrorReceiptSchema, CeremonyID: definition.CeremonyID, Phase: mpcceremony.Phase1, Index: 1, AcceptedHeadID: "sha256:" + strings.Repeat("5a", 32), Files: []mpcceremony.ArtifactRef{commandArtifact("phase1/chain-0001.json", "test public chain")}, Mirror: enrollment.Identity, StorageLocationSHA256: "sha256:" + strings.Repeat("6b", 32), StoredAt: "2026-07-24T12:00:00Z"} + raw, err := mpcceremony.MarshalCanonical(record) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "record.json") + writeDecisionTestFile(t, path, raw, 0600) + keyPath := filepath.Join(root, "mirror.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(key.Seed())), 0600) + o, err := parseOpsSign(append(trust, "--record-type", "mirror-receipt", "--record", path, "--signing-key", keyPath, "--reviewed", "--out", filepath.Join(root, "record.sig"))) + if err != nil { + t.Fatal(err) + } + if _, err := executeOpsSign(o); err != nil { + t.Fatal(err) + } + actual, err := os.ReadFile(o.OutPath) + if err != nil { + t.Fatal(err) + } + sig, err := mpcceremony.ImportOperationalSignature(raw, enrollment.Identity.KeyID, key.Public().(ed25519.PublicKey), ed25519.Sign(key, raw)) + if err != nil { + t.Fatal(err) + } + expected, _ := mpcceremony.MarshalCanonical(sig) + if !bytes.Equal(actual, expected) { + t.Fatal("mirror signature failed independent comparison") + } + // Signing authenticates the claim only; full receipt verification still + // requires the retained transcript and signed enrollment. + other := record + other.CeremonyID = "sha256:" + strings.Repeat("77", 32) + changed, _ := mpcceremony.MarshalCanonical(other) + writeDecisionTestFile(t, path, changed, 0600) + o.OutPath = filepath.Join(root, "other.sig") + if _, err := executeOpsSign(o); err == nil { + t.Fatal("signed another ceremony's receipt") + } +} + +func TestGuidedEnrollmentSigning(t *testing.T) { + definition, _, coordinator := decisionSignFixture(t) + for _, role := range []string{"coordinator", "release-signer", "auditor", "participant", "public-witness", "mirror-operator"} { + t.Run(role, func(t *testing.T) { + root := t.TempDir() + trust := writeInspectionTrustFixture(t, root, definition, coordinator) + id, key := definition.Coordinator, coordinator + switch role { + case "release-signer": + id = definition.ReleaseSigner + key = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)) + case "auditor": + id = definition.Auditors[0] + key = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, 32)) + case "participant": + id = definition.Roster[0].Identity + key = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, 32)) + case "public-witness", "mirror-operator": + key = ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x60}, 32)) + var err error + id, err = mpcceremony.NewIdentity("external-owner", "External owner", "external-key", key.Public().(ed25519.PublicKey)) + if err != nil { + t.Fatal(err) + } + } + raw, _ := json.Marshal(id) + writeDecisionTestFile(t, filepath.Join(root, "identity.json"), raw, 0600) + writeDecisionTestFile(t, filepath.Join(root, "disclosure.txt"), []byte("Test operator shares this machine; not independent.\n"), 0600) + writeDecisionTestFile(t, filepath.Join(root, "key.hex"), []byte(hex.EncodeToString(key.Seed())+"\n"), 0600) + prepare, err := parseOpsPrepareEnrollment(append(append([]string{}, trust...), "--identity", filepath.Join(root, "identity.json"), "--role", role, "--role-index", "2", "--disclosure", filepath.Join(root, "disclosure.txt"), "--enrolled-at", "2026-07-24T12:00:00Z", "--out-dir", filepath.Join(root, "enrollment"))) + if err != nil { + t.Fatal(err) + } + if _, err = executeOpsPrepareEnrollment(prepare); err != nil { + t.Fatal(err) + } + args := append(append([]string{}, trust...), "--record-type", "enrollment", "--record", filepath.Join(root, "enrollment/canonical.json"), "--signing-key", filepath.Join(root, "key.hex"), "--out", filepath.Join(root, "enrollment/enrollment.sig")) + sign, err := parseOpsSign(args) + if err != nil { + t.Fatal(err) + } + if _, err = executeOpsSign(sign); err == nil { + t.Fatal("signed without review") + } + sign.Reviewed = true + stale := sign + stale.ReviewedSHA256 = "incorrect-reviewed-digest" + if _, err = executeOpsSign(stale); err == nil { + t.Fatal("signed bytes different from the review") + } + badTrust := sign + badTrust.CeremonySignaturePath = filepath.Join(root, "bad-ceremony.sig") + writeDecisionTestFile(t, badTrust.CeremonySignaturePath, []byte("{}"), 0600) + if _, err = executeOpsSign(badTrust); err == nil { + t.Fatal("signed without authentic ceremony signature") + } + disclosurePath := filepath.Join(root, "enrollment/enrollments", id.ID, "disclosure.txt") + disclosureRaw, err := os.ReadFile(disclosurePath) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, disclosurePath, []byte("changed disclosure"), 0600) + if _, err = executeOpsSign(sign); err == nil { + t.Fatal("signed changed disclosure") + } + writeDecisionTestFile(t, disclosurePath, disclosureRaw, 0600) + wrong := sign + wrong.SigningKey = filepath.Join(root, "wrong.hex") + writeDecisionTestFile(t, wrong.SigningKey, []byte(hex.EncodeToString(bytes.Repeat([]byte{0x77}, 32))), 0600) + if _, err = executeOpsSign(wrong); err == nil { + t.Fatal("accepted wrong owner") + } + unsupported := sign + unsupported.RecordType = "decision" + if _, err = executeOpsSign(unsupported); err == nil { + t.Fatal("accepted unrelated record type") + } + if _, err = executeOpsSign(sign); err != nil { + t.Fatal(err) + } + canonical, err := os.ReadFile(sign.RecordPath) + if err != nil { + t.Fatal(err) + } + sigRaw, err := os.ReadFile(sign.OutPath) + if err != nil { + t.Fatal(err) + } + var sig mpcceremony.DetachedSignature + if err = json.Unmarshal(sigRaw, &sig); err != nil { + t.Fatal(err) + } + // Compare against the canonical detached signature produced by the existing verifier/importer. + expected, err := mpcceremony.ImportOperationalSignature(canonical, id.KeyID, key.Public().(ed25519.PublicKey), ed25519.Sign(key, canonical)) + if err != nil { + t.Fatal(err) + } + expectedRaw, _ := mpcceremony.MarshalCanonical(expected) + if !bytes.Equal(sigRaw, expectedRaw) { + t.Fatal("unexpected detached signature") + } + if _, err = executeOpsSign(sign); err == nil { + t.Fatal("overwrote signature") + } + tampered := sign + tampered.OutPath = filepath.Join(root, "tampered.sig") + writeDecisionTestFile(t, sign.RecordPath, append(canonical, '\n'), 0600) + if _, err = executeOpsSign(tampered); err == nil { + t.Fatal("accepted noncanonical bytes") + } + }) + } +} diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index dab986b1..5446d9d5 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -436,6 +436,14 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { options, err := parseOpsPreparePublicWitnessReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPreparePublicWitnessReceipt, options return invocation, wrapCommandError(err, "ops", "prepare-public-witness-receipt") + case "prepare-enrollment": + options, err := parseOpsPrepareEnrollment(args[1:]) + invocation.Command, invocation.Options = CommandOpsPrepareEnrollment, options + return invocation, wrapCommandError(err, "ops", "prepare-enrollment") + case "sign": + options, err := parseOpsSign(args[1:]) + invocation.Command, invocation.Options = CommandOpsSign, options + return invocation, wrapCommandError(err, "ops", "sign") case "prepare-mirror-receipt": options, err := parseOpsPrepareMirrorReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPrepareMirrorReceipt, options diff --git a/cmd/mpc-ceremony/public_witness_ops_test.go b/cmd/mpc-ceremony/public_witness_ops_test.go index c7fd29d1..42774139 100644 --- a/cmd/mpc-ceremony/public_witness_ops_test.go +++ b/cmd/mpc-ceremony/public_witness_ops_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "encoding/hex" "errors" "os" "path/filepath" @@ -58,7 +59,7 @@ func TestPreparePublicWitnessReceiptAuthenticatesClosureEnrollmentAndOutput(t *t writeDecisionTestFile(t, closurePath, closeBytes, 0o600) writeDecisionTestFile(t, closureSignaturePath, closeSignature, 0o600) - witness, witnessBytes, witnessSignature, _ := commandSignedExternalEnrollment( + witness, witnessBytes, witnessSignature, witnessKey := commandSignedExternalEnrollment( t, definition, mpcceremony.EnrollmentPublicWitness, @@ -95,6 +96,15 @@ func TestPreparePublicWitnessReceiptAuthenticatesClosureEnrollmentAndOutput(t *t if err != nil { t.Fatal(err) } + keyPath := filepath.Join(root, "witness-key.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(witnessKey.Seed())), 0600) + signed, err := executeOpsSign(OpsSignOptions{OpsExportSigningOptions: OpsExportSigningOptions{RecordType: "public-witness", RecordPath: result.Outputs["canonical"], CeremonyPath: trust.CeremonyPath, CeremonySignaturePath: trust.CeremonySignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyFile}, SigningKey: keyPath, OutPath: filepath.Join(root, "witness-signed.sig"), Reviewed: true}) + if err != nil { + t.Fatal(err) + } + if signed.Outputs["signature"] == "" { + t.Fatal("no verified witness signature") + } if bytes.Contains(canonical, []byte(location)) { t.Fatal("canonical receipt contains cleartext publication location") } diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 739d6a8d..a30ecbd0 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -39,6 +39,8 @@ const ( CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" CommandOpsExportSigning Command = "ops export-signing" + CommandOpsPrepareEnrollment Command = "ops prepare-enrollment" + CommandOpsSign Command = "ops sign" CommandOpsImportSig Command = "ops import-signature" CommandOpsVerify Command = "ops verify" CommandDecisionPrepare Command = "decision prepare" diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 21eff171..61edc5e4 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -58,6 +58,8 @@ Commands: 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-enrollment Derive your ceremony-bound public enrollment + ops sign Sign your reviewed enrollment or observation offline 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 @@ -496,6 +498,29 @@ 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 prepare-enrollment": `Usage: + mpc-ceremony ops prepare-enrollment --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --identity PUBLIC_IDENTITY_JSON \ + --role ROLE [--role-index N] --disclosure PUBLIC_TEXT_FILE \ + --enrolled-at RFC3339 --out-dir FRESH_DIR + +Derives the canonical enrollment from the authenticated definition and the +owner's public identity and disclosure. Internal role indices are derived; +external witness/mirror indices are assigned through the coordination channel. +No private key is read. Share the entire public export with the disclosure. +`, + "ops sign": `Usage: + mpc-ceremony ops sign --record-type TYPE --record CANONICAL_FILE \ + --ceremony FILE --ceremony-signature FILE --coordinator-public-key-file KEY \ + --signing-key OWN_KEY_FILE --reviewed [--reviewed-sha256 HEX] --out FRESH_SIGNATURE_JSON + +Offline owner signing for enrollment, public-witness or mirror-receipt only. +Authenticates the ceremony, canonical record and owner key. Review the exact +record and associated disclosure/observations before --reviewed. This signs +your claim; it does not independently observe publication or prove independence. +Enrollment signing requires its matching disclosure tree beside the record. +The optional reviewed hash binds signing to bytes previously shown by a helper. `, "ops export-signing": `Usage: mpc-ceremony ops export-signing --record-type TYPE --record FILE \ diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index 67051bf5..9831fff3 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -56,6 +56,19 @@ mandatory. A tiny release cannot satisfy the production K=21 decision gate. ## Coordinated distribution +Guided enrollment and receipt signing requires the commands `ops prepare-enrollment` +and `ops sign`. The former derives the frozen ceremony/roster bindings; the latter +accepts only enrollment, public-witness and mirror-receipt records, authenticates +the definition, and checks the owner key. Enrollment signing also verifies the +accompanying disclosure. Helpers can bind approval to displayed bytes with +`--reviewed-sha256`. These are signed owner claims, not proof of independent +people, publication observations, retained storage or physical erasure. Complete +operational-bundle verification remains required before release. + +Release proof-tool first, then update the downstream Relay proof-tool pins and +retest that published pairing. Local development-image tests are not release +provenance and must not be presented as verification of published assets. + 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 diff --git a/internal/mpcceremony/enrollment_prepare.go b/internal/mpcceremony/enrollment_prepare.go new file mode 100644 index 00000000..0b6f7ac5 --- /dev/null +++ b/internal/mpcceremony/enrollment_prepare.go @@ -0,0 +1,38 @@ +package mpcceremony + +import ( + "bytes" + "encoding/json" + "errors" +) + +// PrepareEnrollment derives frozen ceremony bindings; it does not claim that +// the identity belongs to an independent human or that its owner consented. +func PrepareEnrollment(definition CeremonyDefinition, definitionBytes []byte, identity Identity, role EnrollmentRole, externalIndex uint16, disclosure ArtifactRef, enrolledAt string) (EnrollmentRecord, error) { + if err := definition.Validate(); err != nil { + return EnrollmentRecord{}, err + } + canonical, err := MarshalCanonical(definition) + if err != nil { + return EnrollmentRecord{}, err + } + if !bytes.Equal(canonical, definitionBytes) { + return EnrollmentRecord{}, errors.New("definition bytes do not match the validated definition") + } + roster, err := json.Marshal(definition.Roster) + if err != nil { + return EnrollmentRecord{}, err + } + index := externalIndex + if _, _, position, ok := definitionRoleAt(definition, identity.ID); ok { + index = position + } + record := EnrollmentRecord{EnrollmentRecordSchema, definition.CeremonyID, NewDigest(definitionBytes), taggedSHA256(roster), identity, role, index, disclosure, enrolledAt} + if err := record.Validate(); err != nil { + return EnrollmentRecord{}, err + } + if err := verifyEnrollmentBinding(definition, definitionBytes, record); err != nil { + return EnrollmentRecord{}, err + } + return record, nil +} From 657b634bd82eaee2f175af3e6746273e7526fe39 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Tue, 8 Sep 2026 20:59:20 +0900 Subject: [PATCH 52/64] Expose authenticated ceremony journey metadata (#21) --- cmd/mpc-ceremony/executor.go | 3 +- cmd/mpc-ceremony/inspect.go | 1 + cmd/mpc-ceremony/inspect_test.go | 11 ++++ cmd/mpc-ceremony/journey_inspection.go | 64 +++++++++++++++++++ cmd/mpc-ceremony/journey_inspection_test.go | 37 +++++++++++ cmd/mpc-ceremony/types.go | 14 ++-- docs/mpc-ceremony-release.md | 7 ++ internal/mpcceremony/inspect.go | 18 ++++++ internal/mpcceremony/inspect_deadline_test.go | 59 +++++++++++++++++ 9 files changed, 207 insertions(+), 7 deletions(-) create mode 100644 cmd/mpc-ceremony/journey_inspection.go create mode 100644 cmd/mpc-ceremony/journey_inspection_test.go create mode 100644 internal/mpcceremony/inspect_deadline_test.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index a68e8aca..6d652596 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -920,7 +920,8 @@ func executeInspect(options InspectOptions) (CommandResult, error) { "inspected ceremony at %s depth; inspection is read-only and authorizes nothing", result.Depth, ), - Outputs: outputs, + Outputs: outputs, + JourneyInspection: inspectJourney(result), }, nil } diff --git a/cmd/mpc-ceremony/inspect.go b/cmd/mpc-ceremony/inspect.go index 60ad5a84..7c1c311a 100644 --- a/cmd/mpc-ceremony/inspect.go +++ b/cmd/mpc-ceremony/inspect.go @@ -146,6 +146,7 @@ func inspectDefinition(definition mpcceremony.CeremonyDefinition) DefinitionInsp Phase1Participants: append([]string(nil), definition.Phase1Policy.Participants...), Phase2Participants: append([]string(nil), definition.Phase2Policy.Participants...), R1CS: definition.Circuit.R1CS, + Journey: inspectDefinitionJourney(definition), } } diff --git a/cmd/mpc-ceremony/inspect_test.go b/cmd/mpc-ceremony/inspect_test.go index c80913e2..47f2472f 100644 --- a/cmd/mpc-ceremony/inspect_test.go +++ b/cmd/mpc-ceremony/inspect_test.go @@ -87,6 +87,8 @@ func TestInspectCommandsAuthenticateSignedDefinitionAndChain(t *testing.T) { command: CommandInspectDefinition, check: func(result CommandResult) bool { return result.DefinitionInspection != nil && + result.DefinitionInspection.Journey != nil && + len(result.DefinitionInspection.Journey.RequiredEnrollments) == 2+len(definition.Auditors)+len(definition.Roster) && result.DefinitionInspection.CeremonyID == definition.CeremonyID && reflect.DeepEqual(result.DefinitionInspection.Phase1Participants, definition.Phase1Policy.Participants) }, @@ -129,6 +131,15 @@ func TestInspectCommandsAuthenticateSignedDefinitionAndChain(t *testing.T) { if code := runCLI(context.Background(), tests[1].args, &stdout, &stderr, workflowExecutor{}); code == 0 { t.Fatalf("tampered chain was accepted: stdout = %q", stdout.String()) } + writeDecisionTestFile(t, ceremonyPath, append(definitionBytes, '\n'), 0600) + stdout.Reset() + stderr.Reset() + if code := runCLI(context.Background(), tests[0].args, &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatal("tampered definition emitted metadata") + } + if strings.Contains(stdout.String(), "required_enrollments") || strings.Contains(stdout.String(), "definition_inspection") { + t.Fatal("unauthenticated roster metadata leaked into a failed result") + } } func TestInspectParticipantMatchesRosterPositionsWithoutExposingPrivateKey(t *testing.T) { diff --git a/cmd/mpc-ceremony/journey_inspection.go b/cmd/mpc-ceremony/journey_inspection.go new file mode 100644 index 00000000..45be9da4 --- /dev/null +++ b/cmd/mpc-ceremony/journey_inspection.go @@ -0,0 +1,64 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "proof-tool/internal/mpcceremony" + +type ExpectedEnrollmentInspection struct { + Role mpcceremony.EnrollmentRole `json:"role"` + RoleIndex int `json:"role_index"` + Identity mpcceremony.Identity `json:"identity"` +} + +type DefinitionJourneyInspection struct { + Schema string `json:"schema"` + RequiredEnrollments []ExpectedEnrollmentInspection `json:"required_enrollments"` + MinimumPublicWitnesses int `json:"minimum_public_witnesses"` + MinimumMirrorsPerAcceptedHead int `json:"minimum_mirrors_per_accepted_head"` + ObserverRequirementSource string `json:"observer_requirement_source"` +} + +type PhaseJourneyInspection struct { + Phase string `json:"phase"` + Started bool `json:"started"` + AcceptedCount int `json:"accepted_count"` + ScheduledTotal int `json:"scheduled_total"` + HeadRecordID string `json:"head_record_id"` + NextParticipantID string `json:"next_participant_id,omitempty"` + Closed bool `json:"closed"` + CloseID string `json:"close_id,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + BeaconRound uint64 `json:"beacon_round,omitempty"` + BeaconScheduledAt string `json:"beacon_scheduled_at,omitempty"` + WitnessObservationDeadline string `json:"witness_observation_deadline,omitempty"` + MissingArtifacts []string `json:"missing_artifacts"` +} + +type JourneyInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + Depth string `json:"depth"` + Phases []PhaseJourneyInspection `json:"phases"` +} + +func inspectDefinitionJourney(d mpcceremony.CeremonyDefinition) *DefinitionJourneyInspection { + r := &DefinitionJourneyInspection{Schema: "proof-tool-mpc-definition-journey-v1", MinimumPublicWitnesses: 2, MinimumMirrorsPerAcceptedHead: 2, ObserverRequirementSource: "operational-bundle verifier minimum; an agreed witness quorum can require more"} + r.RequiredEnrollments = append(r.RequiredEnrollments, ExpectedEnrollmentInspection{mpcceremony.EnrollmentCoordinator, 1, d.Coordinator}, ExpectedEnrollmentInspection{mpcceremony.EnrollmentReleaseSigner, 1, d.ReleaseSigner}) + for n, id := range d.Auditors { + r.RequiredEnrollments = append(r.RequiredEnrollments, ExpectedEnrollmentInspection{mpcceremony.EnrollmentAuditor, n + 1, id}) + } + for n, p := range d.Roster { + r.RequiredEnrollments = append(r.RequiredEnrollments, ExpectedEnrollmentInspection{mpcceremony.EnrollmentParticipant, n + 1, p.Identity}) + } + return r +} + +func inspectJourney(result mpcceremony.InspectResult) *JourneyInspection { + r := &JourneyInspection{Schema: "proof-tool-mpc-journey-inspection-v1", CeremonyID: result.CeremonyID, Mode: result.Mode, Depth: result.Depth} + for _, p := range result.Phases { + r.Phases = append(r.Phases, PhaseJourneyInspection{Phase: string(p.Phase), Started: p.Started, AcceptedCount: p.AcceptedCount, ScheduledTotal: p.ScheduledTotal, HeadRecordID: p.HeadRecordID, NextParticipantID: p.NextParticipantID, Closed: p.Closed, CloseID: p.CloseID, ClosedAt: p.ClosedAt, BeaconRound: p.BeaconRound, BeaconScheduledAt: p.BeaconScheduledAt, WitnessObservationDeadline: p.WitnessObservationDeadline, MissingArtifacts: append([]string(nil), p.MissingArtifacts...)}) + } + return r +} diff --git a/cmd/mpc-ceremony/journey_inspection_test.go b/cmd/mpc-ceremony/journey_inspection_test.go new file mode 100644 index 00000000..c6aa59fd --- /dev/null +++ b/cmd/mpc-ceremony/journey_inspection_test.go @@ -0,0 +1,37 @@ +package main + +import ( + "testing" + + "proof-tool/internal/mpcceremony" +) + +func TestDefinitionJourneyProjectsEveryRequiredEnrollment(t *testing.T) { + d, _, _ := decisionSignFixture(t) + j := inspectDefinitionJourney(d) + if len(j.RequiredEnrollments) != 2+len(d.Auditors)+len(d.Roster) { + t.Fatal("omitted required identity") + } + if j.RequiredEnrollments[0].Identity != d.Coordinator || j.RequiredEnrollments[1].Identity != d.ReleaseSigner { + t.Fatal("incorrect coordinator or signer") + } + for n, id := range d.Auditors { + v := j.RequiredEnrollments[2+n] + if v.Identity != id || v.Role != mpcceremony.EnrollmentAuditor || v.RoleIndex != n+1 { + t.Fatal("incorrect auditor assignment") + } + } + for n, p := range d.Roster { + v := j.RequiredEnrollments[2+len(d.Auditors)+n] + if v.Identity != p.Identity || v.Role != mpcceremony.EnrollmentParticipant || v.RoleIndex != n+1 { + t.Fatal("incorrect participant assignment") + } + } + if j.MinimumPublicWitnesses != 2 || j.MinimumMirrorsPerAcceptedHead != 2 || j.ObserverRequirementSource == "" { + t.Fatal("operational verifier minimums omitted") + } + d.Auditors[0].DisplayName = "changed" + if j.RequiredEnrollments[2].Identity.DisplayName == "changed" { + t.Fatal("projection aliases mutable roster") + } +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index a30ecbd0..943dd7ec 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -322,12 +322,13 @@ type InspectEnrollmentOptions struct { } 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"` + 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"` + Journey *DefinitionJourneyInspection `json:"journey,omitempty"` } type ChainRecordInspection struct { @@ -436,6 +437,7 @@ type CommandResult struct { ChainInspection *ChainInspection `json:"chain_inspection,omitempty"` ParticipantInspection *ParticipantInspection `json:"participant_inspection,omitempty"` EnrollmentInspection *EnrollmentInspection `json:"enrollment_inspection,omitempty"` + JourneyInspection *JourneyInspection `json:"journey_inspection,omitempty"` } type Executor interface { diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index 9831fff3..39d80f86 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -69,6 +69,13 @@ Release proof-tool first, then update the downstream Relay proof-tool pins and retest that published pairing. Local development-image tests are not release provenance and must not be presented as verification of published assets. +Read-only guided-journey metadata is included in JSON inspection results. +`inspect definition` reports every required roster enrollment and labels observer +minimums as operational-verifier rules, not extra fields of signed policy. +`inspect` reports authenticated closure IDs, beacon times and the latest allowed +witness observation time. This is local retained state, not proof of global +freshness, actual observations, independent operators, or release authorization. + 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 diff --git a/internal/mpcceremony/inspect.go b/internal/mpcceremony/inspect.go index 56a027b2..59b30731 100644 --- a/internal/mpcceremony/inspect.go +++ b/internal/mpcceremony/inspect.go @@ -10,6 +10,7 @@ import ( "os" "path/filepath" "strings" + "time" ) // InspectDepthMetadata verifies signatures and structure only: the signed @@ -58,6 +59,12 @@ type PhaseInspection struct { // MissingArtifacts lists referenced artifacts that are absent or have the // wrong size. Empty means every referenced artifact is present. MissingArtifacts []string + // These times come only from the authenticated and validated close record. + CloseID string + ClosedAt string + BeaconRound uint64 + BeaconScheduledAt string + WitnessObservationDeadline string } // InspectResult is the full read-only inspection report. @@ -202,6 +209,17 @@ func inspectPhase( return inspection, nil, err } inspection.Closed = closed + if closed { + roundTime, err := QuicknetRoundTime(closeRecord.BeaconRound) + if err != nil { + return inspection, nil, err + } + inspection.CloseID = closeRecord.CloseID + inspection.ClosedAt = closeRecord.ClosedAt + inspection.BeaconRound = closeRecord.BeaconRound + inspection.BeaconScheduledAt = roundTime.UTC().Format(time.RFC3339Nano) + inspection.WitnessObservationDeadline = roundTime.Add(-time.Duration(trusted.Definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second).UTC().Format(time.RFC3339Nano) + } var beacon BeaconRecord if closed { diff --git a/internal/mpcceremony/inspect_deadline_test.go b/internal/mpcceremony/inspect_deadline_test.go new file mode 100644 index 00000000..ae34756e --- /dev/null +++ b/internal/mpcceremony/inspect_deadline_test.go @@ -0,0 +1,59 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "testing" + "time" +) + +func TestInspectPhaseProjectsOnlyAuthenticatedWitnessWindow(t *testing.T) { + f := newOperationalBundleFixture(t) + for source, target := range map[string]string{ + f.bundle.Phase1.AcceptedChain.Record.Name: "phase1/chain-0001.json", + f.bundle.Phase1.AcceptedChain.Signature.Name: "phase1/chain-0001.sig", + f.bundle.Phase1.Close.Record.Name: "phase1/closure/record.json", + f.bundle.Phase1.Close.Signature.Name: "phase1/closure/record.sig", + } { + raw, err := os.ReadFile(filepath.Join(f.root, source)) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(f.root, target) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + } + trusted := &TrustedCeremony{Definition: f.definition, CoordinatorPublicKey: f.coordinatorKey.Public().(ed25519.PublicKey)} + opts := InspectCeremonyOptions{TranscriptRoot: f.root} + p, _, err := inspectPhase(trusted, nil, opts, Phase1, nil) + if err != nil { + t.Fatal(err) + } + if !p.Closed || p.CloseID == "" || p.ClosedAt == "" || p.BeaconRound == 0 { + t.Fatal("closure identity omitted") + } + round, err := QuicknetRoundTime(p.BeaconRound) + if err != nil { + t.Fatal(err) + } + deadline := round.Add(-time.Duration(f.definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second) + if p.BeaconScheduledAt != round.UTC().Format(time.RFC3339Nano) || p.WitnessObservationDeadline != deadline.UTC().Format(time.RFC3339Nano) { + t.Fatal("incorrect signed observation window") + } + path := filepath.Join(f.root, "phase1/closure/record.json") + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, append(raw, '\n'), 0600); err != nil { + t.Fatal(err) + } + if _, _, err := inspectPhase(trusted, nil, opts, Phase1, nil); err == nil { + t.Fatal("tampered closure produced trusted timing") + } +} From d205f91cac8ef9a1f21a5b7c350f80e89596a63e Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 9 Sep 2026 11:40:24 +0900 Subject: [PATCH 53/64] Guide operational evidence preparation and require two beacon operators (#23) * Prepare verified operational bundles and require two beacon operators * Avoid unused canonical export assignment --- cmd/mpc-ceremony/executor.go | 2 + cmd/mpc-ceremony/main.go | 2 +- cmd/mpc-ceremony/ops_bundle.go | 95 ++++++ cmd/mpc-ceremony/ops_bundle_test.go | 39 +++ cmd/mpc-ceremony/ops_guided.go | 11 +- cmd/mpc-ceremony/parse.go | 4 + cmd/mpc-ceremony/types.go | 1 + cmd/mpc-ceremony/usage.go | 21 +- docs/mpc-ceremony-release.md | 10 +- docs/trusted-setup-ceremony.md | 19 ++ internal/mpcceremony/operational.go | 4 +- internal/mpcceremony/operational_bundle.go | 24 ++ .../mpcceremony/operational_bundle_test.go | 12 +- internal/mpcceremony/operational_prepare.go | 284 ++++++++++++++++++ .../mpcceremony/operational_prepare_test.go | 71 +++++ internal/mpcceremony/operational_test.go | 29 +- .../testdata/workflowhelper/main.go | 3 +- .../main.go | 4 +- scripts/run-mpc-k21-local-rehearsal.sh | 4 +- 19 files changed, 615 insertions(+), 24 deletions(-) create mode 100644 cmd/mpc-ceremony/ops_bundle.go create mode 100644 cmd/mpc-ceremony/ops_bundle_test.go create mode 100644 internal/mpcceremony/operational_prepare.go create mode 100644 internal/mpcceremony/operational_prepare_test.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 6d652596..14b35c4c 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -79,6 +79,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeOpsExportSigning(invocation.Options.(OpsExportSigningOptions)) case CommandOpsPrepareEnrollment: return executeOpsPrepareEnrollment(invocation.Options.(OpsPrepareEnrollmentOptions)) + case CommandOpsPrepareBundle: + return executeOpsPrepareBundle(invocation.Options.(OpsPrepareBundleOptions)) case CommandOpsSign: return executeOpsSign(invocation.Options.(OpsSignOptions)) case CommandOpsImportSig: diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 50d84b64..dddad139 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -275,7 +275,7 @@ command: }, "ops": { "export-signing": {}, "help": {}, "import-signature": {}, - "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "verify": {}, + "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "prepare-bundle": {}, "verify": {}, }, "release": {"help": {}, "sign": {}, "verify": {}}, "rehearsal": {"help": {}, "init": {}}, diff --git a/cmd/mpc-ceremony/ops_bundle.go b/cmd/mpc-ceremony/ops_bundle.go new file mode 100644 index 00000000..46bf853f --- /dev/null +++ b/cmd/mpc-ceremony/ops_bundle.go @@ -0,0 +1,95 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "proof-tool/internal/mpcceremony" +) + +type OpsPrepareBundleOptions struct { + CeremonyPath, CeremonySignaturePath, CoordinatorPublicKeyFile string + EvidenceRoot, OutDir string + WitnessQuorum uint +} + +func parseOpsPrepareBundle(args []string) (OpsPrepareBundleOptions, error) { + var o OpsPrepareBundleOptions + f := commandFlagSet("ops prepare-bundle") + addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + f.StringVar(&o.EvidenceRoot, "evidence-root", "", "public-only evidence directory; never a keys or credentials directory") + f.StringVar(&o.OutDir, "out-dir", "", "fresh unsigned bundle export directory") + f.UintVar(&o.WitnessQuorum, "witness-quorum", 2, "agreed minimum public witnesses per phase (2-32)") + if err := parseFlags(f, args); err != nil { + return o, err + } + if o.WitnessQuorum < 2 || o.WitnessQuorum > 32 { + return o, errors.New("witness quorum must be between 2 and 32") + } + return o, requireValues(pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--evidence-root", o.EvidenceRoot), pathValue("--out-dir", o.OutDir)) +} + +func executeOpsPrepareBundle(o OpsPrepareBundleOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: o.CeremonyPath, DefinitionSignaturePath: o.CeremonySignaturePath, CoordinatorPublicKeyPath: o.CoordinatorPublicKeyFile}) + if err != nil { + return CommandResult{}, err + } + if o.WitnessQuorum < 2 || o.WitnessQuorum > 32 { + return CommandResult{}, errors.New("witness quorum must be between 2 and 32") + } + prepared, err := mpcceremony.PrepareOperationalEvidence(trusted.Definition, o.EvidenceRoot, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + return CommandResult{}, err + } + for index, phase := range []*mpcceremony.PhaseOperationalEvidence{&prepared.Bundle.Phase1, &prepared.Bundle.Phase2} { + phase.PublicWitnessQuorum = uint8(o.WitnessQuorum) + if o.WitnessQuorum > 2 && len(phase.PublicWitnessReceipts) < int(o.WitnessQuorum) { + prepared.Missing = append(prepared.Missing, fmt.Sprintf("phase%d: agreed witness quorum is %d, found %d records", index+1, o.WitnessQuorum, len(phase.PublicWitnessReceipts))) + } + } + if len(prepared.Missing) > 0 { + return CommandResult{}, fmt.Errorf("evidence preparation incomplete (discovery is not verification):\n- %s\nCollect the original public records and signatures from their owners, retaining referenced relative paths, then retry. Do not invent or backdate evidence", strings.Join(prepared.Missing, "\n- ")) + } + raw, err := mpcceremony.MarshalCanonical(prepared.Bundle) + if err != nil { + return CommandResult{}, err + } + if err := verifyBundleDraft(trusted, o.EvidenceRoot, raw, prepared.Bundle); err != nil { + return CommandResult{}, fmt.Errorf("evidence found but verification failed; no bundle was exported: %w", err) + } + request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordEvidenceBundle, raw) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + _, path, err := writeOperationalSigningExport(o.OutDir, raw, requestBytes) + if err != nil { + return CommandResult{}, err + } + canonical := filepath.Join(o.OutDir, "evidence-bundle.json") + if err := writeFreshOperationalFile(canonical, raw, 0600); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Summary: "assembled and verified the referenced operational evidence; the exported bundle is UNSIGNED and cannot authorize release", Outputs: map[string]string{"canonical": canonical, "signing_request": path}}, nil +} + +func verifyBundleDraft(trusted *mpcceremony.TrustedCeremony, root string, raw []byte, bundle mpcceremony.OperationalEvidenceBundle) error { + if root == "" { + return errors.New("bundle preparation/signing requires --evidence-root") + } + p1, err := mpcceremony.LoadAuthenticatedCloseEvidence(root, bundle.Phase1.Close) + if err != nil { + return err + } + p2, err := mpcceremony.LoadAuthenticatedCloseEvidence(root, bundle.Phase2.Close) + if err != nil { + return err + } + return mpcceremony.VerifyOperationalEvidenceDraft(mpcceremony.VerifyOperationalEvidenceOptions{Definition: trusted.Definition, CoordinatorPublicKey: trusted.CoordinatorPublicKey, EvidenceRoot: root, BundleBytes: raw, Phase1Close: p1, Phase2Close: p2}) +} diff --git a/cmd/mpc-ceremony/ops_bundle_test.go b/cmd/mpc-ceremony/ops_bundle_test.go new file mode 100644 index 00000000..33704b30 --- /dev/null +++ b/cmd/mpc-ceremony/ops_bundle_test.go @@ -0,0 +1,39 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrepareBundleExplainsMissingEvidenceWithoutWriting(t *testing.T) { + root := t.TempDir() + definition, _, coordinator := decisionSignFixture(t) + trust := writeInspectionTrustFixture(t, root, definition, coordinator) + out := filepath.Join(root, "fresh-bundle") + o, err := parseOpsPrepareBundle(append(trust, "--evidence-root", root, "--out-dir", out)) + if err != nil { + t.Fatal(err) + } + _, err = executeOpsPrepareBundle(o) + if err == nil || !strings.Contains(err.Error(), "phase1 closure") || !strings.Contains(err.Error(), "phase2 closure") || !strings.Contains(err.Error(), "signed enrollment") { + t.Fatalf("missing actionable diagnostics: %v", err) + } + if _, err := os.Lstat(out); !os.IsNotExist(err) { + t.Fatal("incomplete preparation wrote an export") + } + o.CoordinatorPublicKeyFile = filepath.Join(root, "untrusted-missing-key") + if _, err := executeOpsPrepareBundle(o); err == nil || strings.Contains(err.Error(), "evidence preparation incomplete") { + t.Fatal("scanned before authenticating trust", err) + } +} + +func TestPrepareBundleCommandHelpAndRequiredInputs(t *testing.T) { + if _, err := parseOpsPrepareBundle(nil); err == nil { + t.Fatal("missing trust accepted") + } + if _, err := parseInvocation([]string{"ops", "prepare-bundle", "--help"}); err == nil { + t.Fatal("expected help request") + } +} diff --git a/cmd/mpc-ceremony/ops_guided.go b/cmd/mpc-ceremony/ops_guided.go index c910466a..a94f30be 100644 --- a/cmd/mpc-ceremony/ops_guided.go +++ b/cmd/mpc-ceremony/ops_guided.go @@ -26,6 +26,7 @@ type OpsSignOptions struct { OpsExportSigningOptions SigningKey, OutPath string ReviewedSHA256 string + EvidenceRoot string Reviewed bool } @@ -56,6 +57,7 @@ func parseOpsSign(args []string) (OpsSignOptions, error) { f.StringVar(&o.OutPath, "out", "", "fresh detached signature JSON") f.BoolVar(&o.Reviewed, "reviewed", false, "owner reviewed exact record and confirms its claims") f.StringVar(&o.ReviewedSHA256, "reviewed-sha256", "", "optional SHA-256 of the exact bytes shown during interactive review") + f.StringVar(&o.EvidenceRoot, "evidence-root", "", "complete public evidence root, required for bundle signing") if err := parseFlags(f, args); err != nil { return o, err } @@ -133,8 +135,8 @@ func executeOpsSign(o OpsSignOptions) (CommandResult, error) { return CommandResult{}, errors.New("owner must review the exact record and explicitly supply --reviewed") } kind := mpcceremony.OperationalRecordType(o.RecordType) - if kind != mpcceremony.RecordEnrollment && kind != mpcceremony.RecordPublicWitness && kind != mpcceremony.RecordMirrorReceipt { - return CommandResult{}, errors.New("ops sign is restricted to enrollment, public-witness and mirror-receipt records") + if kind != mpcceremony.RecordEnrollment && kind != mpcceremony.RecordPublicWitness && kind != mpcceremony.RecordMirrorReceipt && kind != mpcceremony.RecordEvidenceBundle { + return CommandResult{}, errors.New("ops sign is restricted to enrollment, public-witness, mirror-receipt and fully verified evidence-bundle records") } canonical, record, trusted, err := loadBoundOperationalRecord(kind, o.RecordPath, o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile) if err != nil { @@ -143,6 +145,11 @@ func executeOpsSign(o OpsSignOptions) (CommandResult, error) { if o.ReviewedSHA256 != "" && o.ReviewedSHA256 != fmt.Sprintf("%x", sha256.Sum256(canonical)) { return CommandResult{}, errors.New("record changed since owner review") } + if bundle, ok := record.(*mpcceremony.OperationalEvidenceBundle); ok { + if err := verifyBundleDraft(trusted, o.EvidenceRoot, canonical, *bundle); err != nil { + return CommandResult{}, fmt.Errorf("bundle evidence must verify before accessing the signing key: %w", err) + } + } definitionBytes, err := canonicalDefinition(trusted) if err != nil { return CommandResult{}, err diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 5446d9d5..3de33162 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -432,6 +432,10 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)} } switch args[0] { + case "prepare-bundle": + options, err := parseOpsPrepareBundle(args[1:]) + invocation.Command, invocation.Options = CommandOpsPrepareBundle, options + return invocation, wrapCommandError(err, "ops", "prepare-bundle") case "prepare-public-witness-receipt": options, err := parseOpsPreparePublicWitnessReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPreparePublicWitnessReceipt, options diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 943dd7ec..c0623c53 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -40,6 +40,7 @@ const ( CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" CommandOpsExportSigning Command = "ops export-signing" CommandOpsPrepareEnrollment Command = "ops prepare-enrollment" + CommandOpsPrepareBundle Command = "ops prepare-bundle" CommandOpsSign Command = "ops sign" CommandOpsImportSig Command = "ops import-signature" CommandOpsVerify Command = "ops verify" diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 61edc5e4..7abbd27a 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -515,12 +515,29 @@ No private key is read. Share the entire public export with the disclosure. --ceremony FILE --ceremony-signature FILE --coordinator-public-key-file KEY \ --signing-key OWN_KEY_FILE --reviewed [--reviewed-sha256 HEX] --out FRESH_SIGNATURE_JSON -Offline owner signing for enrollment, public-witness or mirror-receipt only. +Owner signing for enrollment, public-witness, mirror-receipt or evidence-bundle. +Bundle signing additionally requires --evidence-root DIR and verifies every +referenced operational record before reading the coordinator's signing key. Authenticates the ceremony, canonical record and owner key. Review the exact record and associated disclosure/observations before --reviewed. This signs your claim; it does not independently observe publication or prove independence. Enrollment signing requires its matching disclosure tree beside the record. The optional reviewed hash binds signing to bytes previously shown by a helper. +`, + "ops prepare-bundle": `Usage: + mpc-ceremony ops prepare-bundle --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --evidence-root PUBLIC_DIR --out-dir FRESH_DIR \ + [--witness-quorum 2] + +Discovers bounded public JSON and signatures; never point it at private keys or +credentials. Reports missing or conflicting evidence by phase and turn. Keep +original relative paths when collecting public records from their owners. +Set witness-quorum to your agreed minimum per phase (2-32), not a lower value +chosen to fit the available receipts. +If complete, independently verifies all referenced evidence and exports an +UNSIGNED canonical bundle and signing request. It does not invent records, +backdate observations, or sign for other roles. Release still requires the +coordinator's bundle signature and successful signed-bundle verification. `, "ops export-signing": `Usage: mpc-ceremony ops export-signing --record-type TYPE --record FILE \ @@ -550,6 +567,6 @@ Authenticates canonical bytes, immutable ceremony fields, enrolled signer, and detached signature. Receipt verification requires the exact related handoff. Evidence-bundle verification requires the complete local evidence root and validates both authenticated chains, every custody transfer, independent -mirrors and public witnesses, and three distinct beacon relay operators. +mirrors and public witnesses, and at least two distinct beacon relay operators. `, } diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index 39d80f86..5c326129 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -58,13 +58,21 @@ mandatory. A tiny release cannot satisfy the production K=21 decision gate. Guided enrollment and receipt signing requires the commands `ops prepare-enrollment` and `ops sign`. The former derives the frozen ceremony/roster bindings; the latter -accepts only enrollment, public-witness and mirror-receipt records, authenticates +accepts enrollment, public-witness, mirror-receipt and evidence-bundle records, authenticates the definition, and checks the owner key. Enrollment signing also verifies the accompanying disclosure. Helpers can bind approval to displayed bytes with `--reviewed-sha256`. These are signed owner claims, not proof of independent people, publication observations, retained storage or physical erasure. Complete operational-bundle verification remains required before release. +`ops prepare-bundle` discovers original public records below an explicit evidence +root, reports missing/conflicting evidence by phase and turn, and exports an +unsigned bundle only after full evidence verification. It does not author +missing custody records or recreate observations. Bundle signing requires +`--evidence-root` and repeats verification before accessing the coordinator key. +The signed bundle remains mandatory for release; unsigned preparation is not +release authorization. + Release proof-tool first, then update the downstream Relay proof-tool pins and retest that published pairing. Local development-image tests are not release provenance and must not be presented as verification of published assets. diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index 369e8413..efb4461e 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -88,6 +88,25 @@ constitute production approval; each production ceremony requires an explicit, independently reviewed go/no-go record before any ceremony binary or artifact is used. +## Beacon relay evidence + +Final release requires matching, cryptographically verified responses from at +least **two distinct relay operators** for each phase's committed drand round. +Relay IDs and endpoint digests must also differ; multiple hostnames belonging +to one operator do not count as different operators. Every supplied response +must verify against the pinned network and exact committed round. + +This reduces the operational minimum from three operators to two, trading one +source of retrieval redundancy for availability during a relay outage. It does +not change drand's cryptographic threshold, the future-round requirement, or +the other signed operational-evidence and release checks. Operator identities +remain authenticated coordinator claims, not proof of organizational independence. + +Existing three-operator evidence remains valid. Older binaries still require +three; use an explicitly reviewed compatible release. Do not edit an existing +signed ceremony's software allowlist or replace its pinned binary to force an +in-progress ceremony through a changed verifier policy. + ## Toxic Waste Handling gnark samples the Groth16 trapdoor in process memory during `groth16.Setup`. diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index 666de88c..f7344c21 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -465,8 +465,8 @@ func (r MultiRelayBeaconEvidence) Validate() error { if err := validateID("network", r.Network); err != nil { return err } - if len(r.Observations) < 3 || len(r.Observations) > 16 { - return errors.New("multi-relay beacon evidence requires between 3 and 16 observations") + if len(r.Observations) < 2 || len(r.Observations) > 16 { + return errors.New("multi-relay beacon evidence requires between 2 and 16 observations") } relayIDs := make(map[string]struct{}, len(r.Observations)) operatorIDs := make(map[string]struct{}, len(r.Observations)) diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index b0f84e48..842e8752 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -260,6 +260,30 @@ func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) ( ); err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("operational evidence bundle: %w", err) } + return verifyOperationalEvidenceContents(options, bundle) +} + +// VerifyOperationalEvidenceDraft checks every existing signed record without +// claiming that the coordinator has signed the assembled bundle itself. +func VerifyOperationalEvidenceDraft(options VerifyOperationalEvidenceOptions) error { + if err := options.Definition.Validate(); err != nil { + return err + } + if len(options.CoordinatorPublicKey) != ed25519.PublicKeySize { + return errors.New("coordinator public key is invalid") + } + var bundle OperationalEvidenceBundle + if err := UnmarshalCanonical(options.BundleBytes, &bundle); err != nil { + return err + } + if err := bundle.Validate(); err != nil { + return err + } + _, err := verifyOperationalEvidenceContents(options, bundle) + return err +} + +func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, bundle OperationalEvidenceBundle) (VerifiedOperationalEvidence, error) { if bundle.CeremonyID != options.Definition.CeremonyID || bundle.CoordinatorID != options.Definition.Coordinator.ID || bundle.CoordinatorKeyID != options.Definition.Coordinator.KeyID { diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 7573d0f9..92bce23e 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -231,7 +231,7 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Fatal("participant self-witness unexpectedly accepted") } }) - t.Run("less than three relay operators", func(t *testing.T) { + t.Run("less than two relay operators", func(t *testing.T) { f := newOperationalBundleFixture(t) pair := f.bundle.Phase1.MultiRelayBeaconEvidence var evidence MultiRelayBeaconEvidence @@ -242,13 +242,13 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { if err := UnmarshalCanonical(recordBytes, &evidence); err != nil { t.Fatal(err) } - evidence.Observations = evidence.Observations[:2] + evidence.Observations = evidence.Observations[:1] rewriteInvalidSignedPair(t, f.root, pair, evidence, f.coordinatorKey, f.definition.Coordinator.KeyID) f.bundle.Phase1.MultiRelayBeaconEvidence = refreshPair(t, f.root, pair) - f.bundle.Phase1.RawBeaconResponses = f.bundle.Phase1.RawBeaconResponses[:2] + f.bundle.Phase1.RawBeaconResponses = f.bundle.Phase1.RawBeaconResponses[:1] resignBundle(t, &f) if err := verify(f); err == nil { - t.Fatal("two-operator relay evidence unexpectedly accepted") + t.Fatal("single-operator relay evidence unexpectedly accepted") } }) t.Run("reused phase rounds", func(t *testing.T) { @@ -823,7 +823,9 @@ func buildOperationalPhaseFixture( if err != nil { t.Fatal(err) } - operatorIDs := []string{"cloudflare", "drand", "secureweb3"} + // Exercise the minimum quorum throughout signed bundle verification. + // These are local fixtures, not claims of actual independent retrievals. + operatorIDs := []string{"cloudflare", "drand"} rawRefs := make([]ArtifactRef, len(operatorIDs)) observations := make([]RelayObservation, len(operatorIDs)) rawMap := make(map[string][]byte, len(operatorIDs)) diff --git a/internal/mpcceremony/operational_prepare.go b/internal/mpcceremony/operational_prepare.go new file mode 100644 index 00000000..7d93ea54 --- /dev/null +++ b/internal/mpcceremony/operational_prepare.go @@ -0,0 +1,284 @@ +package mpcceremony + +import ( + "encoding/json" + "errors" + "fmt" + "io/fs" + "path/filepath" + "slices" + "strings" +) + +// Preparation discovers only bounded public JSON/signature files below an +// explicitly selected evidence root. Discovery is never signature verification. +type OperationalPreparation struct { + Bundle OperationalEvidenceBundle `json:"bundle"` + Missing []string `json:"missing"` +} + +type discoveredOperational struct { + ref ArtifactRef + value any +} + +func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt string) (OperationalPreparation, error) { + result := OperationalPreparation{Bundle: OperationalEvidenceBundle{ + Schema: OperationalEvidenceBundleSchema, CeremonyID: definition.CeremonyID, + CoordinatorID: definition.Coordinator.ID, CoordinatorKeyID: definition.Coordinator.KeyID, + AssembledAt: assembledAt, Enrollments: []SignedArtifactRefs{}, GovernanceRecords: []SignedArtifactRefs{}, + }, Missing: []string{}} + if err := definition.Validate(); err != nil { + return result, err + } + if err := validateTimestamp("assembled_at", assembledAt); err != nil { + return result, err + } + var records []discoveredOperational + signatures := map[string][]ArtifactRef{} + seen := map[string]bool{} + count, total := 0, int64(0) + err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + count++ + if count > 20000 { + return errors.New("public evidence tree exceeds 20000 entries") + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("public evidence path is a symlink: %s", path) + } + if entry.IsDir() { + return nil + } + if !strings.HasSuffix(path, ".json") && !strings.HasSuffix(path, ".sig") { + return nil + } + name, err := filepath.Rel(root, path) + if err != nil { + return err + } + name = filepath.ToSlash(name) + resolved, err := resolveArtifactPath(root, name) + if err != nil { + return err + } + raw, err := readRegularBounded(resolved, 16<<20) + if err != nil { + return err + } + total += int64(len(raw)) + if total > 64<<20 { + return errors.New("public JSON inventory exceeds 64 MiB") + } + var header struct{ Schema, CeremonyID string } + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return nil + } + _ = json.Unmarshal(fields["schema"], &header.Schema) + _ = json.Unmarshal(fields["ceremony_id"], &header.CeremonyID) + ref := ArtifactRef{Name: name, Digest: NewDigest(raw)} + if header.Schema == DetachedSignatureSchema { + var signature DetachedSignature + if err := UnmarshalCanonical(raw, &signature); err != nil { + return fmt.Errorf("signature %s: %w", name, err) + } + if err := signature.Validate(); err != nil { + return err + } + key := "signature/" + ref.Digest.SHA256 + if !seen[key] { + signatures[signature.SignedSHA256] = append(signatures[signature.SignedSHA256], ref) + seen[key] = true + } + return nil + } + if header.CeremonyID != definition.CeremonyID { + return nil + } + var value any + switch header.Schema { + case EnrollmentRecordSchema: + value = &EnrollmentRecord{} + case TransferHandoffSchema: + value = &TransferHandoff{} + case TransferReceiptSchema: + value = &TransferReceipt{} + case PublicWitnessReceiptSchema: + value = &PublicWitnessReceipt{} + case ImmutableMirrorReceiptSchema: + value = &ImmutableMirrorReceipt{} + case MultiRelayBeaconEvidenceSchema: + value = &MultiRelayBeaconEvidence{} + case GovernanceRecordSchema: + value = &GovernanceRecord{} + case ChainSchema: + value = &Chain{} + case CloseRecordSchema: + value = &CloseRecord{} + default: + return nil + } + if err := UnmarshalCanonical(raw, value); err != nil { + return fmt.Errorf("public record %s: %w", name, err) + } + if !seen[ref.Digest.SHA256] { + records = append(records, discoveredOperational{ref, value}) + seen[ref.Digest.SHA256] = true + } + return nil + }) + if err != nil { + return result, err + } + pair := func(record discoveredOperational, label string) SignedArtifactRefs { + found := signatures[record.ref.Digest.SHA256] + if len(found) != 1 { + result.Missing = append(result.Missing, fmt.Sprintf("%s: need exactly one matching signature (found %d)", label, len(found))) + return SignedArtifactRefs{Record: record.ref} + } + return SignedArtifactRefs{Record: record.ref, Signature: found[0]} + } + all := func(label string, match func(any) bool) []SignedArtifactRefs { + found := []SignedArtifactRefs{} + for _, record := range records { + if match(record.value) { + found = append(found, pair(record, label)) + } + } + slices.SortFunc(found, func(a, b SignedArtifactRefs) int { return strings.Compare(a.Record.Name, b.Record.Name) }) + return found + } + pick := func(label string, match func(any) bool) (SignedArtifactRefs, any) { + var found []discoveredOperational + for _, record := range records { + if match(record.value) { + found = append(found, record) + } + } + if len(found) != 1 { + result.Missing = append(result.Missing, fmt.Sprintf("%s: need exactly one record (found %d); collect missing evidence or investigate conflicts", label, len(found))) + return SignedArtifactRefs{}, nil + } + return pair(found[0], label), found[0].value + } + result.Bundle.Enrollments = all("enrollment", func(v any) bool { _, ok := v.(*EnrollmentRecord); return ok }) + result.Bundle.GovernanceRecords = all("governance", func(v any) bool { _, ok := v.(*GovernanceRecord); return ok }) + // Protocol-enforced roster completeness is checked again by the verifier. + for _, id := range append([]Identity{definition.Coordinator, definition.ReleaseSigner}, preparationRoster(definition)...) { + count := 0 + for _, record := range records { + if e, ok := record.value.(*EnrollmentRecord); ok && e.Identity.ID == id.ID { + count++ + } + } + if count != 1 { + result.Missing = append(result.Missing, "Collect one signed enrollment from "+id.ID) + } + } + for _, phase := range []Phase{Phase1, Phase2} { + p := PhaseOperationalEvidence{Phase: phase, PublicWitnessQuorum: 2, AcceptedHeads: []AcceptedHeadOperationalEvidence{}, RawBeaconResponses: []ArtifactRef{}} + label := string(phase) + closePair, closeAny := pick(label+" closure", func(v any) bool { c, ok := v.(*CloseRecord); return ok && c.Phase == phase }) + p.Close = closePair + if closeAny == nil { + continue + } + close := closeAny.(*CloseRecord) + chainPair, chainAny := pick(label+" final accepted chain", func(v any) bool { + c, ok := v.(*Chain) + return ok && c.Phase == phase && len(c.Records) > 0 && c.Records[len(c.Records)-1].RecordID == close.ChainHeadID + }) + p.AcceptedChain = chainPair + p.PublicWitnessReceipts = all(label+" witness", func(v any) bool { + w, ok := v.(*PublicWitnessReceipt) + return ok && w.Phase == phase && w.CloseID == close.CloseID + }) + if len(p.PublicWitnessReceipts) < 2 { + result.Missing = append(result.Missing, label+": collect signed observations from at least two witnesses; expired windows cannot be recreated") + } + p.MultiRelayBeaconEvidence, closeAny = pick(label+" two-operator beacon evidence", func(v any) bool { + b, ok := v.(*MultiRelayBeaconEvidence) + return ok && b.Phase == phase && b.CloseID == close.CloseID + }) + if closeAny != nil { + for _, o := range closeAny.(*MultiRelayBeaconEvidence).Observations { + p.RawBeaconResponses = append(p.RawBeaconResponses, o.RawResponse) + } + slices.SortFunc(p.RawBeaconResponses, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + } + if chainAny != nil { + for _, head := range chainAny.(*Chain).Records { + h := AcceptedHeadOperationalEvidence{Index: head.Index, PredecessorHeadID: head.PreviousRecordID, AcceptedHeadID: head.RecordID} + scope := fmt.Sprintf("%s turn %d (%s)", phase, head.Index, head.ParticipantID) + h.AcceptedChainPrefix, _ = pick(scope+" accepted chain prefix", func(v any) bool { + c, ok := v.(*Chain) + return ok && c.Phase == phase && len(c.Records) == int(head.Index) && c.Records[len(c.Records)-1].RecordID == head.RecordID + }) + for _, outbound := range []bool{true, false} { + direction := "return" + sender := head.ParticipantID + if outbound { + direction = "outbound" + sender = definition.Coordinator.ID + } + handoff, _ := pick(scope+" "+direction+" handoff (sender-signed)", func(v any) bool { + r, ok := v.(*TransferHandoff) + return ok && r.Phase == phase && r.Index == head.Index && r.PredecessorHeadID == head.PreviousRecordID && r.SenderID == sender + }) + receipt, _ := pick(scope+" "+direction+" receipt (receiver-signed)", func(v any) bool { + r, ok := v.(*TransferReceipt) + return ok && r.HandoffSHA256 == handoff.Record.Digest.SHA256 + }) + if outbound { + h.OutboundHandoff, h.OutboundReceipt = handoff, receipt + } else { + h.ReturnHandoff, h.ReturnReceipt = handoff, receipt + } + } + h.MirrorReceipts = all(scope+" mirror receipt", func(v any) bool { + r, ok := v.(*ImmutableMirrorReceipt) + return ok && r.Phase == phase && r.Index == head.Index && r.AcceptedHeadID == head.RecordID + }) + // Byte-identical chain files can exist under several public names. + // Preserve the exact names the mirrors actually signed, not an + // arbitrary discovery alias. Full verification checks all mirrors. + for _, discovered := range records { + mirror, ok := discovered.value.(*ImmutableMirrorReceipt) + if !ok || mirror.Phase != phase || mirror.Index != head.Index || mirror.AcceptedHeadID != head.RecordID { + continue + } + for _, ref := range mirror.Files { + if ref.Digest == h.AcceptedChainPrefix.Record.Digest { + h.AcceptedChainPrefix.Record = ref + } + if ref.Digest == h.AcceptedChainPrefix.Signature.Digest { + h.AcceptedChainPrefix.Signature = ref + } + } + break + } + if len(h.MirrorReceipts) < 2 { + result.Missing = append(result.Missing, scope+": collect at least two signed mirror receipts for this exact head") + } + p.AcceptedHeads = append(p.AcceptedHeads, h) + } + } + if phase == Phase1 { + result.Bundle.Phase1 = p + } else { + result.Bundle.Phase2 = p + } + } + return result, nil +} + +func preparationRoster(d CeremonyDefinition) []Identity { + ids := append([]Identity{}, d.Auditors...) + for _, p := range d.Roster { + ids = append(ids, p.Identity) + } + return ids +} diff --git a/internal/mpcceremony/operational_prepare_test.go b/internal/mpcceremony/operational_prepare_test.go new file mode 100644 index 00000000..dce9edeb --- /dev/null +++ b/internal/mpcceremony/operational_prepare_test.go @@ -0,0 +1,71 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestPrepareOperationalEvidenceVerifiesExistingRecords(t *testing.T) { + f := newOperationalBundleFixture(t) + p, err := PrepareOperationalEvidence(f.definition, f.root, f.bundle.AssembledAt) + if err != nil { + t.Fatal(err) + } + if len(p.Missing) > 0 { + t.Fatal(p.Missing) + } + raw, err := MarshalCanonical(p.Bundle) + if err != nil { + t.Fatal(err) + } + o := VerifyOperationalEvidenceOptions{Definition: f.definition, CoordinatorPublicKey: f.coordinatorKey.Public().(ed25519.PublicKey), EvidenceRoot: f.root, BundleBytes: raw, Phase1Close: f.phase1Close, Phase2Close: f.phase2Close} + if err := VerifyOperationalEvidenceDraft(o); err != nil { + t.Fatal(err) + } + if _, err := VerifyOperationalEvidenceBundle(o); err == nil { + t.Fatal("unsigned preparation passed signed release gate") + } + signature, err := SignExact(raw, f.definition.Coordinator.KeyID, f.coordinatorKey) + if err != nil { + t.Fatal(err) + } + o.BundleSignatureBytes, err = MarshalCanonical(signature) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyOperationalEvidenceBundle(o); err != nil { + t.Fatal("signed prepared bundle rejected", err) + } + // Discovery does not substitute for validation of referenced public bytes. + path := filepath.Join(f.root, f.bundle.Phase1.RawBeaconResponses[0].Name) + if err := os.WriteFile(path, []byte("tampered"), 0600); err != nil { + t.Fatal(err) + } + if err := VerifyOperationalEvidenceDraft(o); err == nil { + t.Fatal("tampered raw beacon accepted") + } +} + +func TestPrepareOperationalEvidenceReportsMissingAndRejectsSymlinks(t *testing.T) { + f := newOperationalBundleFixture(t) + missing := f.bundle.Phase1.AcceptedHeads[0].ReturnReceipt.Signature.Name + if err := os.Remove(filepath.Join(f.root, missing)); err != nil { + t.Fatal(err) + } + p, err := PrepareOperationalEvidence(f.definition, f.root, f.bundle.AssembledAt) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(strings.Join(p.Missing, "\n"), "phase1 turn 1") || !strings.Contains(strings.Join(p.Missing, "\n"), "return receipt") { + t.Fatal(p.Missing) + } + if err := os.Symlink("missing", filepath.Join(f.root, "unexpected.json")); err != nil { + t.Fatal(err) + } + if _, err := PrepareOperationalEvidence(f.definition, f.root, f.bundle.AssembledAt); err == nil { + t.Fatal("symlink accepted") + } +} diff --git a/internal/mpcceremony/operational_test.go b/internal/mpcceremony/operational_test.go index 95a48d96..f664010f 100644 --- a/internal/mpcceremony/operational_test.go +++ b/internal/mpcceremony/operational_test.go @@ -247,7 +247,7 @@ func TestPublicWitnessQuorumRejectsDuplicateIdentityAndKey(t *testing.T) { } } -func TestMultiRelayEvidenceRequiresThreeOperatorsEndpointsAndMatchingRandomness(t *testing.T) { +func TestMultiRelayEvidenceRequiresTwoOperatorsEndpointsAndMatchingRandomness(t *testing.T) { base := RelayObservation{ RelayID: "relay-01", OperatorID: "drand", @@ -282,10 +282,16 @@ func TestMultiRelayEvidenceRequiresThreeOperatorsEndpointsAndMatchingRandomness( if err := evidence.Validate(); err != nil { t.Fatal(err) } - onlyTwo := evidence - onlyTwo.Observations = onlyTwo.Observations[:2] - if err := onlyTwo.Validate(); err == nil { - t.Fatal("two-relay evidence unexpectedly accepted") + evidence.Observations = evidence.Observations[:2] + if err := evidence.Validate(); err != nil { + t.Fatalf("two distinct relay operators rejected: %v", err) + } + for _, count := range []int{0, 1} { + tooFew := evidence + tooFew.Observations = tooFew.Observations[:count] + if err := tooFew.Validate(); err == nil { + t.Fatalf("%d relay observations unexpectedly accepted", count) + } } duplicateOperator := evidence duplicateOperator.Observations = append([]RelayObservation(nil), evidence.Observations...) @@ -295,10 +301,21 @@ func TestMultiRelayEvidenceRequiresThreeOperatorsEndpointsAndMatchingRandomness( } mismatch := evidence mismatch.Observations = append([]RelayObservation(nil), evidence.Observations...) - mismatch.Observations[2].VerifiedRandomness = strings.Repeat("00", 32) + mismatch.Observations[1].VerifiedRandomness = strings.Repeat("00", 32) if err := mismatch.Validate(); err == nil { t.Fatal("relay randomness disagreement unexpectedly accepted") } + duplicateEndpoint := evidence + duplicateEndpoint.Observations = append([]RelayObservation(nil), evidence.Observations...) + duplicateEndpoint.Observations[1].EndpointSHA256 = duplicateEndpoint.Observations[0].EndpointSHA256 + if err := duplicateEndpoint.Validate(); err == nil { + t.Fatal("duplicate relay endpoint unexpectedly accepted") + } + tooMany := evidence + tooMany.Observations = make([]RelayObservation, 17) + if err := tooMany.Validate(); err == nil { + t.Fatal("more than 16 observations unexpectedly accepted") + } } func TestGovernanceRestartRequiresDistinctNewCeremonyAndEvidence(t *testing.T) { diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 6e4f0ed1..f0b2b7d2 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -888,7 +888,8 @@ func writeRelayFixture(root, name string, raw []byte, retrievedAt string) (strin rows := []string{ "relay_id\toperator_id\tendpoint_sha256\tretrieved_at\tfilename", } - for index := 1; index <= 3; index++ { + // Exercise release verification with the minimum two synthetic operators. + for index := 1; index <= 2; index++ { filename := fmt.Sprintf("relay-%02d.json", index) if err := os.WriteFile(filepath.Join(dir, filename), raw, 0o600); err != nil { return "", err diff --git a/scripts/mpc-rehearsal-operational-evidence/main.go b/scripts/mpc-rehearsal-operational-evidence/main.go index d900f337..d9909565 100644 --- a/scripts/mpc-rehearsal-operational-evidence/main.go +++ b/scripts/mpc-rehearsal-operational-evidence/main.go @@ -792,7 +792,7 @@ func loadRelayInputs(directory string) ([]relayInput, error) { if err != nil { return nil, err } - if len(rows) < 4 || len(rows) > 17 || + if len(rows) < 3 || len(rows) > 17 || !slices.Equal(rows[0], []string{ "relay_id", "operator_id", @@ -800,7 +800,7 @@ func loadRelayInputs(directory string) ([]relayInput, error) { "retrieved_at", "filename", }) { - return nil, errors.New("relays.tsv must have the exact header and 3-16 observations") + return nil, errors.New("relays.tsv must have the exact header and 2-16 observations") } safeName := regexp.MustCompile(`^[a-z0-9][a-z0-9._-]*\.json$`) safeID := regexp.MustCompile(`^[a-z0-9][a-z0-9._:-]{0,127}$`) diff --git a/scripts/run-mpc-k21-local-rehearsal.sh b/scripts/run-mpc-k21-local-rehearsal.sh index 71a77ee3..f832f5c4 100755 --- a/scripts/run-mpc-k21-local-rehearsal.sh +++ b/scripts/run-mpc-k21-local-rehearsal.sh @@ -457,8 +457,8 @@ const fs = require("node:fs"); const rows = fs.readFileSync(process.argv[2], "utf8").split("\n"); if (rows.at(-1) === "") rows.pop(); const header = "relay_id\toperator_id\tendpoint_sha256\tretrieved_at\tfilename"; -if (rows.length < 4 || rows.length > 17 || rows[0] !== header) { - throw new Error("relays.tsv must have the exact header and 3-16 observations"); +if (rows.length < 3 || rows.length > 17 || rows[0] !== header) { + throw new Error("relays.tsv must have the exact header and 2-16 observations"); } const idPattern = /^[a-z0-9][a-z0-9._:-]{0,127}$/; const filenamePattern = /^[a-z0-9][a-z0-9._-]*\.json$/; From 1405da9e1830dcb19faa1b0ba42a0935d9c095d7 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 9 Sep 2026 12:43:47 +0900 Subject: [PATCH 54/64] Prepare release bundle without replacing collected evidence (#24) * Preserve collected evidence when preparing release bundle * Report bundle directory close errors --- cmd/mpc-ceremony/ops_bundle.go | 74 +++++++++++++++++-- cmd/mpc-ceremony/ops_bundle_test.go | 110 ++++++++++++++++++++++++++++ cmd/mpc-ceremony/usage.go | 5 +- docs/mpc-ceremony-release.md | 7 ++ 4 files changed, 189 insertions(+), 7 deletions(-) diff --git a/cmd/mpc-ceremony/ops_bundle.go b/cmd/mpc-ceremony/ops_bundle.go index 46bf853f..32dbe4a4 100644 --- a/cmd/mpc-ceremony/ops_bundle.go +++ b/cmd/mpc-ceremony/ops_bundle.go @@ -3,6 +3,7 @@ package main import ( "errors" "fmt" + "os" "path/filepath" "strings" "time" @@ -21,7 +22,7 @@ func parseOpsPrepareBundle(args []string) (OpsPrepareBundleOptions, error) { f := commandFlagSet("ops prepare-bundle") addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) f.StringVar(&o.EvidenceRoot, "evidence-root", "", "public-only evidence directory; never a keys or credentials directory") - f.StringVar(&o.OutDir, "out-dir", "", "fresh unsigned bundle export directory") + f.StringVar(&o.OutDir, "out-dir", "", "evidence-root/operational; existing evidence is preserved, bundle outputs must be fresh") f.UintVar(&o.WitnessQuorum, "witness-quorum", 2, "agreed minimum public witnesses per phase (2-32)") if err := parseFlags(f, args); err != nil { return o, err @@ -68,17 +69,78 @@ func executeOpsPrepareBundle(o OpsPrepareBundleOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - _, path, err := writeOperationalSigningExport(o.OutDir, raw, requestBytes) + canonical, path, err := writeEvidenceBundleExport(o.EvidenceRoot, o.OutDir, raw, requestBytes) if err != nil { return CommandResult{}, err } - canonical := filepath.Join(o.OutDir, "evidence-bundle.json") - if err := writeFreshOperationalFile(canonical, raw, 0600); err != nil { - return CommandResult{}, err - } return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Summary: "assembled and verified the referenced operational evidence; the exported bundle is UNSIGNED and cannot authorize release", Outputs: map[string]string{"canonical": canonical, "signing_request": path}}, nil } +// Only this export may reuse an evidence directory. Other signing exports keep +// their fresh-directory contract. Root-relative operations prevent path escape. +func writeEvidenceBundleExport(evidenceRoot, outDir string, canonical, request []byte) (bundlePath, requestPath string, err error) { + rootAbs, err := filepath.Abs(evidenceRoot) + if err != nil { + return "", "", err + } + outAbs, err := filepath.Abs(outDir) + if err != nil || outAbs != filepath.Join(rootAbs, "operational") { + return "", "", errors.New("bundle output must be evidence-root/operational, as required by release verification") + } + if len(canonical) == 0 || len(request) == 0 { + return "", "", errors.New("refuse empty bundle export") + } + root, err := os.OpenRoot(rootAbs) + if err != nil { + return "", "", err + } + defer func() { err = errors.Join(err, root.Close()) }() + if err := root.Mkdir("operational", 0700); err != nil && !os.IsExist(err) { + return "", "", err + } + info, err := root.Lstat("operational") + if err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return "", "", errors.New("operational output must be a real directory, not a symlink") + } + dir, err := root.OpenRoot("operational") + if err != nil { + return "", "", err + } + defer func() { err = errors.Join(err, dir.Close()) }() + for _, name := range []string{"evidence-bundle.json", "evidence-bundle.sig", "signing-request.json"} { + if _, err := dir.Lstat(name); !os.IsNotExist(err) { + return "", "", fmt.Errorf("bundle output %s already exists or cannot be inspected; preserve and inspect it before retrying", name) + } + } + // Reserve the request first with O_EXCL; concurrent preparations cannot both + // proceed. On interruption retain partial outputs for explicit inspection. + for _, output := range []struct { + name string + data []byte + }{{"signing-request.json", request}, {"evidence-bundle.json", canonical}} { + file, err := dir.OpenFile(output.name, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600) + if err != nil { + return "", "", err + } + _, writeErr := file.Write(output.data) + syncErr := file.Sync() + closeErr := file.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return "", "", fmt.Errorf("partial bundle export retained; inspect before retry: %w", err) + } + } + directory, err := dir.Open(".") + if err != nil { + return "", "", err + } + err = directory.Sync() + closeErr := directory.Close() + if err = errors.Join(err, closeErr); err != nil { + return "", "", err + } + return filepath.Join(outDir, "evidence-bundle.json"), filepath.Join(outDir, "signing-request.json"), nil +} + func verifyBundleDraft(trusted *mpcceremony.TrustedCeremony, root string, raw []byte, bundle mpcceremony.OperationalEvidenceBundle) error { if root == "" { return errors.New("bundle preparation/signing requires --evidence-root") diff --git a/cmd/mpc-ceremony/ops_bundle_test.go b/cmd/mpc-ceremony/ops_bundle_test.go index 33704b30..2e8ac657 100644 --- a/cmd/mpc-ceremony/ops_bundle_test.go +++ b/cmd/mpc-ceremony/ops_bundle_test.go @@ -4,6 +4,7 @@ import ( "os" "path/filepath" "strings" + "sync" "testing" ) @@ -37,3 +38,112 @@ func TestPrepareBundleCommandHelpAndRequiredInputs(t *testing.T) { t.Fatal("expected help request") } } + +func TestBundleExportPreservesExistingEvidence(t *testing.T) { + for _, existing := range []bool{false, true} { + root := t.TempDir() + out := filepath.Join(root, "operational") + if existing { + if err := os.Mkdir(out, 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(out, "receipt.json"), []byte("original receipt"), 0600); err != nil { + t.Fatal(err) + } + } + bundle, request, err := writeEvidenceBundleExport(root, out, []byte("bundle"), []byte("request")) + if err != nil { + t.Fatal(err) + } + for path, want := range map[string]string{bundle: "bundle", request: "request"} { + got, err := os.ReadFile(path) + if err != nil || string(got) != want { + t.Fatalf("%s: %q %v", path, got, err) + } + } + if existing { + got, err := os.ReadFile(filepath.Join(out, "receipt.json")) + if err != nil || string(got) != "original receipt" { + t.Fatal("receipt changed", err) + } + } + if _, _, err := writeEvidenceBundleExport(root, out, []byte("replacement"), []byte("replacement")); err == nil { + t.Fatal("overwrite accepted") + } + } +} + +func TestBundleExportRejectsCollisionsAndUnsafePaths(t *testing.T) { + for _, name := range []string{"evidence-bundle.json", "evidence-bundle.sig", "signing-request.json"} { + for _, symlink := range []bool{false, true} { + root := t.TempDir() + out := filepath.Join(root, "operational") + if err := os.Mkdir(out, 0700); err != nil { + t.Fatal(err) + } + path := filepath.Join(out, name) + var err error + if symlink { + err = os.Symlink(filepath.Join(root, "missing"), path) + } else { + err = os.WriteFile(path, []byte("keep"), 0600) + } + if err != nil { + t.Fatal(err) + } + if _, _, err := writeEvidenceBundleExport(root, out, []byte("bundle"), []byte("request")); err == nil { + t.Fatal("collision accepted", name) + } + entries, err := os.ReadDir(out) + if err != nil || len(entries) != 1 { + t.Fatal("collision wrote outputs", err) + } + } + } + root, outside := t.TempDir(), t.TempDir() + if _, _, err := writeEvidenceBundleExport(root, outside, []byte("bundle"), []byte("request")); err == nil { + t.Fatal("wrong release path accepted") + } + if err := os.Symlink(outside, filepath.Join(root, "operational")); err != nil { + t.Fatal(err) + } + if _, _, err := writeEvidenceBundleExport(root, filepath.Join(root, "operational"), []byte("bundle"), []byte("request")); err == nil { + t.Fatal("symlink directory accepted") + } + entries, err := os.ReadDir(outside) + if err != nil || len(entries) != 0 { + t.Fatal("wrote outside evidence root", err) + } +} + +func TestBundleExportConcurrentPreparationsDoNotMix(t *testing.T) { + root := t.TempDir() + out := filepath.Join(root, "operational") + var wg sync.WaitGroup + results := make(chan error, 2) + for _, value := range []string{"first", "second"} { + wg.Go(func() { + _, _, err := writeEvidenceBundleExport(root, out, []byte(value), []byte(value)) + results <- err + }) + } + wg.Wait() + close(results) + success := 0 + for err := range results { + if err == nil { + success++ + } + } + if success != 1 { + t.Fatalf("successful preparations: %d", success) + } + bundle, err := os.ReadFile(filepath.Join(out, "evidence-bundle.json")) + if err != nil { + t.Fatal(err) + } + request, err := os.ReadFile(filepath.Join(out, "signing-request.json")) + if err != nil || string(bundle) != string(request) { + t.Fatal("mixed concurrent outputs", err) + } +} diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 7abbd27a..c5a48a38 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -526,12 +526,15 @@ The optional reviewed hash binds signing to bytes previously shown by a helper. `, "ops prepare-bundle": `Usage: mpc-ceremony ops prepare-bundle --ceremony FILE --ceremony-signature FILE \ - --coordinator-public-key-file KEY --evidence-root PUBLIC_DIR --out-dir FRESH_DIR \ + --coordinator-public-key-file KEY --evidence-root PUBLIC_DIR --out-dir PUBLIC_DIR/operational \ [--witness-quorum 2] Discovers bounded public JSON and signatures; never point it at private keys or credentials. Reports missing or conflicting evidence by phase and turn. Keep original relative paths when collecting public records from their owners. +The operational directory may exist; existing evidence is preserved. Bundle, +signature and signing-request files must not already exist. Interrupted output +is retained for inspection, never automatically overwritten. Set witness-quorum to your agreed minimum per phase (2-32), not a lower value chosen to fit the available receipts. If complete, independently verifies all referenced evidence and exports an diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index 5c326129..490879a1 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -73,6 +73,13 @@ missing custody records or recreate observations. Bundle signing requires The signed bundle remains mandatory for release; unsigned preparation is not release authorization. +Use `--out-dir EVIDENCE_ROOT/operational`. This directory may already contain +collected evidence; preparation preserves it and adds only `evidence-bundle.json` +and `signing-request.json`. Existing bundle, signature, or request files block +preparation rather than being overwritten. Inspect retained outputs after an +interruption before retrying. Release verification requires this exact bundle +location; other signing exports still require fresh directories. + Release proof-tool first, then update the downstream Relay proof-tool pins and retest that published pairing. Local development-image tests are not release provenance and must not be presented as verification of published assets. From 74967b28765db94919bf7e1e8253c0690ba87b2c Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 9 Sep 2026 15:19:30 +0900 Subject: [PATCH 55/64] Complete custody commands and add unsigned public ceremony replay (#22) * Add narrowly scoped helpers for live tiny ceremony rehearsals * Support custody packets and tiny rehearsal proof generation * Add unsigned public replay using the signed audit verification core * Expose verified manifest digest to bind public approval checks * Document and test public replay without signing inputs * Keep public replay command names readable in diagnostics --- cmd/mpc-ceremony/decision.go | 1 + cmd/mpc-ceremony/executor.go | 31 ++- cmd/mpc-ceremony/integration_test.go | 3 + cmd/mpc-ceremony/main.go | 22 +- cmd/mpc-ceremony/ops_custody.go | 217 ++++++++++++++++++ cmd/mpc-ceremony/ops_custody_test.go | 84 +++++++ cmd/mpc-ceremony/ops_guided.go | 10 +- cmd/mpc-ceremony/parse.go | 27 +++ cmd/mpc-ceremony/redaction_test.go | 20 ++ cmd/mpc-ceremony/rehearsal_evidence.go | 143 ++++++++++++ cmd/mpc-ceremony/replay_test.go | 35 +++ cmd/mpc-ceremony/types.go | 4 + cmd/mpc-ceremony/usage.go | 53 ++++- docs/ceremony-custody-workflow.md | 78 +++++++ docs/public-replay.md | 27 +++ internal/mpcceremony/audit.go | 79 ++++--- .../testdata/workflowhelper/main.go | 19 ++ 17 files changed, 818 insertions(+), 35 deletions(-) create mode 100644 cmd/mpc-ceremony/ops_custody.go create mode 100644 cmd/mpc-ceremony/ops_custody_test.go create mode 100644 cmd/mpc-ceremony/rehearsal_evidence.go create mode 100644 cmd/mpc-ceremony/replay_test.go create mode 100644 docs/ceremony-custody-workflow.md create mode 100644 docs/public-replay.md diff --git a/cmd/mpc-ceremony/decision.go b/cmd/mpc-ceremony/decision.go index 4a34fd9c..4925e898 100644 --- a/cmd/mpc-ceremony/decision.go +++ b/cmd/mpc-ceremony/decision.go @@ -118,6 +118,7 @@ func decisionCommandResult( Decision: string(decision.Decision), DecisionID: decision.DecisionID, ReleaseID: decision.Release.ReleaseID, + ReleaseManifestSHA256: decision.Release.Manifest.Artifact.Digest.SHA256, CandidateID: decision.Release.CandidateID, SourceCommit: decision.SourceRelease.SourceCommit, SourceSignedTag: decision.SourceRelease.SignedTag, diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 14b35c4c..1ed31784 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -61,10 +61,16 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeClose(mpcceremony.Phase2, invocation.Options.(CloseOptions)) case CommandPhase2Beacon: return executeBeacon(mpcceremony.Phase2, invocation.Options.(BeaconOptions)) + case CommandRehearsalEvidence: + return executeRehearsalEvidence(invocation.Options.(RehearsalEvidenceOptions)) + case CommandOpsPrepareCustody: + return executeCustody(invocation.Options.(CustodyOptions)) case CommandFinalizePrepare: return executePrepareFinalization(invocation.Options.(PrepareFinalizationOptions)) case CommandFinalizeComplete: return executeFinalize(invocation.Options.(FinalizeOptions)) + case CommandReplay: + return executeReplay(invocation.Options.(AuditOptions)) case CommandAudit: return executeAudit(invocation.Options.(AuditOptions)) case CommandReleaseSign: @@ -562,6 +568,26 @@ func executePrepareFinalization(options PrepareFinalizationOptions) (CommandResu }, nil } +func executeReplay(options AuditOptions) (CommandResult, error) { + trust := trustPaths(options.CeremonyPath, options.CeremonySignaturePath, options.CoordinatorPublicKeyFile) + if err := verifyRunningTrust(trust); err != nil { + return CommandResult{}, err + } + paths, err := replayPaths(trust, options.Replay) + if err != nil { + return CommandResult{}, err + } + circuit, err := compileCircuitForCeremony(trust) + if err != nil { + return CommandResult{}, err + } + id, err := mpcceremony.ReplayCandidate(paths, circuit, options.CandidateBundleDir) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: id, Summary: "independently replayed both phases and reproduced final parameters; no audit signed"}, nil +} + func executeAudit(options AuditOptions) (CommandResult, error) { trust := trustPaths( options.CeremonyPath, @@ -684,8 +710,9 @@ func executeReleaseVerify(options ReleaseVerifyOptions) (CommandResult, error) { return CommandResult{}, err } return CommandResult{ - CeremonyID: result.Transcript.CeremonyID, - Summary: "verified the release signature, bundled audits, native keys, Cardano export, and ceremony coherence", + CeremonyID: result.Transcript.CeremonyID, + ReleaseManifestSHA256: result.ManifestSHA256, + Summary: "verified the release signature, bundled audits, native keys, Cardano export, and ceremony coherence", Outputs: map[string]string{ "keys_dir": options.KeysDir, }, diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 7bb9af87..abc21e64 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -39,6 +39,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"finalize", "prepare"}, {"finalize", "complete"}, {"audit"}, + {"replay"}, {"release"}, {"release", "sign"}, {"release", "verify"}, @@ -217,6 +218,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandFinalizePrepare, Options: PrepareFinalizationOptions{}}, {Command: CommandFinalizeComplete, Options: FinalizeOptions{}}, {Command: CommandAudit, Options: AuditOptions{}}, + {Command: CommandReplay, Options: AuditOptions{}}, {Command: CommandReleaseSign, Options: ReleaseSignOptions{}}, {Command: CommandReleaseVerify, Options: ReleaseVerifyOptions{}}, {Command: CommandDecisionPrepare, Options: DecisionPrepareOptions{}}, @@ -257,6 +259,7 @@ func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { {"phase2", "beacon"}, {"finalize"}, {"audit"}, + {"replay"}, {"release", "sign"}, {"release", "verify"}, {"decision", "sign"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index dddad139..7798bfcf 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -146,6 +146,7 @@ const redactedCLIValue = "" // messages remain useful, but values supplied by the caller are never echoed. func redactCLIError(message string, args []string) string { safeCommandArguments := identifyCLICommandArguments(args) + markOperationalGrammar(args, safeCommandArguments) candidates := make(map[string]struct{}) for index, arg := range args { if _, safe := safeCommandArguments[index]; safe { @@ -253,7 +254,7 @@ command: topLevel := map[string]struct{}{ "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "rehearsal": {}, - "release": {}, + "release": {}, "replay": {}, } if _, ok := topLevel[args[index]]; !ok { return safe @@ -274,9 +275,10 @@ command: "chain": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, }, "ops": { - "export-signing": {}, "help": {}, "import-signature": {}, + "export-signing": {}, "help": {}, "import-signature": {}, "sign": {}, "prepare-enrollment": {}, "prepare-handoff": {}, "prepare-receipt": {}, "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "prepare-bundle": {}, "verify": {}, }, + "finalize": {"prepare": {}, "complete": {}, "rehearsal-evidence": {}}, "release": {"help": {}, "sign": {}, "verify": {}}, "rehearsal": {"help": {}, "init": {}}, } @@ -338,3 +340,19 @@ func writeParseError(message string, args []string, stdout, stderr io.Writer) in } return 2 } + +// Only fixed operational grammar is public. Unknown values and all paths remain +// redacted, including values following a recognized flag. +func markOperationalGrammar(args []string, safe map[int]struct{}) { + for index, arg := range args { + if arg == "--related-record" || arg == "--record-type" || arg == "--reviewed-sha256" || arg == "--evidence-root" { + safe[index] = struct{}{} + } + if index > 0 && args[index-1] == "--record-type" { + switch arg { + case "handoff", "receipt", "enrollment", "public-witness", "mirror-receipt", "beacon-evidence", "evidence-bundle": + safe[index] = struct{}{} + } + } + } +} diff --git a/cmd/mpc-ceremony/ops_custody.go b/cmd/mpc-ceremony/ops_custody.go new file mode 100644 index 00000000..0a2f7b4a --- /dev/null +++ b/cmd/mpc-ceremony/ops_custody.go @@ -0,0 +1,217 @@ +package main + +import ( + "crypto/sha256" + "errors" + "fmt" + "golang.org/x/crypto/blake2b" + "io" + "os" + "path/filepath" + "proof-tool/internal/mpcceremony" + "time" +) + +type CustodyOptions struct { + CeremonyPath, CeremonySignaturePath, CoordinatorPublicKeyFile string + Root, Chain, ChainSignature, Participant, Direction, Candidate, OutDir string + Handoff, HandoffSignature, SenderPublicKey string + Receipt bool +} + +func parseCustody(args []string, receipt bool) (CustodyOptions, error) { + o := CustodyOptions{Receipt: receipt} + f := commandFlagSet("ops prepare-custody") + addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + f.StringVar(&o.Root, "transcript-root", "", "public files at this station") + f.StringVar(&o.OutDir, "out-dir", "", "fresh public signing packet") + if receipt { + f.StringVar(&o.Handoff, "handoff", "", "exact canonical handoff") + f.StringVar(&o.HandoffSignature, "handoff-signature", "", "sender's detached signature") + f.StringVar(&o.SenderPublicKey, "sender-public-key-file", "", "separately trusted sender key") + } else { + f.StringVar(&o.Chain, "chain", "", "authenticated current chain before this turn") + f.StringVar(&o.ChainSignature, "chain-signature", "", "current chain signature") + f.StringVar(&o.Participant, "participant-id", "", "next scheduled participant") + f.StringVar(&o.Direction, "direction", "outbound", "outbound or return") + f.StringVar(&o.Candidate, "candidate-dir", "", "completed public candidate for return handoff") + } + if err := parseFlags(f, args); err != nil { + return o, err + } + if o.CeremonyPath == "" || o.CeremonySignaturePath == "" || o.CoordinatorPublicKeyFile == "" || o.Root == "" || o.OutDir == "" { + return o, errors.New("ceremony trust, transcript root and fresh output directory are required") + } + if receipt && (o.Handoff == "" || o.HandoffSignature == "" || o.SenderPublicKey == "") { + return o, errors.New("receipt requires the handoff, sender signature and trusted sender public key") + } + if !receipt && (o.Chain == "" || o.ChainSignature == "" || o.Participant == "" || (o.Direction != "outbound" && o.Direction != "return") || (o.Direction == "return" && o.Candidate == "")) { + return o, errors.New("handoff requires the current chain, participant, and outbound or return direction; return also requires the candidate") + } + return o, nil +} +func executeCustody(o CustodyOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: o.CeremonyPath, DefinitionSignaturePath: o.CeremonySignaturePath, CoordinatorPublicKeyPath: o.CoordinatorPublicKeyFile}) + if err != nil { + return CommandResult{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + var record any + kind := mpcceremony.RecordHandoff + if o.Receipt { + if _, err := executeOpsVerify(OpsVerifyOptions{RecordType: "handoff", RecordPath: o.Handoff, SignaturePath: o.HandoffSignature, CeremonyPath: o.CeremonyPath, CeremonySignaturePath: o.CeremonySignaturePath, CoordinatorPublicKeyFile: o.CoordinatorPublicKeyFile, SignerPublicKeyFile: o.SenderPublicKey}); err != nil { + return CommandResult{}, err + } + raw, err := readRegularOperationalFile(o.Handoff, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + parsed, err := mpcceremony.ParseOperationalRecord(mpcceremony.RecordHandoff, raw) + if err != nil { + return CommandResult{}, err + } + handoff := parsed.(*mpcceremony.TransferHandoff) + for _, ref := range handoff.Files { + if err := checkCustodyFile(o.Root, ref); err != nil { + return CommandResult{}, err + } + } + receipt, err := mpcceremony.NewTransferReceipt(*handoff, raw, mpcceremony.ReceiptReceiver, now) + if err != nil { + return CommandResult{}, err + } + record = receipt + kind = mpcceremony.RecordReceipt + } else { + chain, err := mpcceremony.LoadSignedChain(trusted, mpcceremony.PhaseTranscriptPaths{RootDir: o.Root, ChainPath: o.Chain, ChainSignaturePath: o.ChainSignature}) + if err != nil { + return CommandResult{}, err + } + index := len(chain.Records) + 1 + var policy mpcceremony.PhasePolicy + if chain.Phase == mpcceremony.Phase1 { + policy = trusted.Definition.Phase1Policy + } else { + policy = trusted.Definition.Phase2Policy + } + if index > len(policy.Participants) || policy.Participants[index-1] != o.Participant { + return CommandResult{}, errors.New("participant is not the next signed turn") + } + participant, ok := trusted.Definition.ParticipantByID(o.Participant) + if !ok { + return CommandResult{}, errors.New("participant is not assigned") + } + head, err := chain.HeadRecordID() + if err != nil { + return CommandResult{}, err + } + payload, err := chain.HeadPayload() + if err != nil { + return CommandResult{}, err + } + sender, recipient := trusted.Definition.Coordinator, participant.Identity + files := []mpcceremony.ArtifactRef{payload} + if o.Direction == "outbound" { + if err := checkCustodyFile(o.Root, payload); err != nil { + return CommandResult{}, err + } + } else { + sender, recipient = recipient, sender + raw, err := readRegularOperationalFile(filepath.Join(o.Candidate, "attestation.json"), maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + var att mpcceremony.ContributionAttestation + if err := mpcceremony.UnmarshalCanonical(raw, &att); err != nil { + return CommandResult{}, err + } + if att.CeremonyID != trusted.Definition.CeremonyID || att.Phase != chain.Phase || int(att.Index) != index || att.ParticipantID != o.Participant || att.PreviousAcceptanceID != head { + return CommandResult{}, errors.New("candidate does not match this turn") + } + files = nil + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} { + path := filepath.Join(o.Candidate, name) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return CommandResult{}, errors.New("return candidate must contain regular public files including cleanup acknowledgment") + } + digest, err := custodyDigest(path) + if err != nil { + return CommandResult{}, err + } + files = append(files, mpcceremony.ArtifactRef{Name: fmt.Sprintf("%s/contributions/%04d/%s", chain.Phase, index, name), Digest: digest}) + } + } + handoff, err := mpcceremony.NewTransferHandoff(trusted.Definition, chain.Phase, uint8(index), head, files, sender, recipient, now, time.Now().UTC().Add(time.Hour).Format(time.RFC3339Nano)) + if err != nil { + return CommandResult{}, err + } + record = handoff + } + canonical, err := mpcceremony.MarshalCanonical(record) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(kind, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + path, requestPath, err := writeOperationalSigningExport(o.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Summary: fmt.Sprintf("Prepared current-time %s; review and sign exact bytes before the next action. File hashes do not prove physical transfer or erasure.", kind), Outputs: map[string]string{"canonical": path, "signing_request": requestPath, "reviewed_sha256": fmt.Sprintf("%x", sha256.Sum256(canonical))}}, nil +} +func checkCustodyFile(root string, ref mpcceremony.ArtifactRef) error { + if err := ref.Validate(); err != nil { + return err + } + path := filepath.Join(root, filepath.FromSlash(ref.Name)) + rel, err := filepath.Rel(root, path) + if err != nil || rel == ".." || len(rel) >= 3 && rel[:3] == "../" { + return errors.New("custody file escapes public root") + } + for current := path; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("custody files cannot traverse symlinks") + } + if current == filepath.Clean(root) { + break + } + if filepath.Dir(current) == current { + return errors.New("invalid custody root") + } + } + digest, err := custodyDigest(path) + if err != nil { + return err + } + if digest != ref.Digest { + return errors.New("retained file differs from handoff digest") + } + return nil +} + +func custodyDigest(path string) (mpcceremony.Digest, error) { + f, err := os.Open(path) + if err != nil { + return mpcceremony.Digest{}, err + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + return mpcceremony.Digest{}, errors.New("custody payload must be a regular file") + } + sha := sha256.New() + blake, _ := blake2b.New256(nil) + size, err := io.Copy(io.MultiWriter(sha, blake), f) + return mpcceremony.Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size}, err +} diff --git a/cmd/mpc-ceremony/ops_custody_test.go b/cmd/mpc-ceremony/ops_custody_test.go new file mode 100644 index 00000000..d1100a86 --- /dev/null +++ b/cmd/mpc-ceremony/ops_custody_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "proof-tool/internal/mpcceremony" + "strings" + "testing" + "time" +) + +func TestCustodySigningAndReceiptRequireExactDeliveredBytes(t *testing.T) { + root := t.TempDir() + definition, _, key := decisionSignFixture(t) + trust := writeInspectionTrustFixture(t, root, definition, key) + payload := []byte("public ceremony payload") + ref := mpcceremony.ArtifactRef{Name: "phase1/genesis.bin", Digest: mpcceremony.NewDigest(payload)} + if err := os.MkdirAll(filepath.Join(root, "phase1"), 0700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, filepath.Join(root, ref.Name), payload, 0600) + now := time.Now().UTC() + handoff, err := mpcceremony.NewTransferHandoff(definition, mpcceremony.Phase1, 1, "sha256:"+strings.Repeat("a", 64), []mpcceremony.ArtifactRef{ref}, definition.Coordinator, definition.Roster[0].Identity, now.Add(-time.Second).Format(time.RFC3339Nano), now.Add(time.Hour).Format(time.RFC3339Nano)) + if err != nil { + t.Fatal(err) + } + raw, err := mpcceremony.MarshalCanonical(handoff) + if err != nil { + t.Fatal(err) + } + record := filepath.Join(root, "handoff.json") + signature := filepath.Join(root, "handoff.sig") + writeDecisionTestFile(t, record, raw, 0600) + keyPath := filepath.Join(root, "private.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(key.Seed())), 0600) + sign, err := parseOpsSign(append(append([]string{}, trust...), "--record-type", "handoff", "--record", record, "--signing-key", keyPath, "--out", signature, "--reviewed", "--reviewed-sha256", fmt.Sprintf("%x", sha256.Sum256(raw)))) + if err != nil { + t.Fatal(err) + } + bad := sign + bad.ReviewedSHA256 = strings.Repeat("0", 64) + if _, err := executeOpsSign(bad); err == nil { + t.Fatal("signed changed reviewed bytes") + } + if _, err := os.Stat(signature); !os.IsNotExist(err) { + t.Fatal("failed review wrote signature") + } + if _, err := executeOpsSign(sign); err != nil { + t.Fatal(err) + } + if _, err := executeOpsSign(sign); err == nil { + t.Fatal("overwrote signature") + } + receipt, err := parseCustody(append(append([]string{}, trust...), "--transcript-root", root, "--handoff", record, "--handoff-signature", signature, "--sender-public-key-file", filepath.Join(root, "coordinator-public-key.hex"), "--out-dir", filepath.Join(root, "receipt")), true) + if err != nil { + t.Fatal(err) + } + result, err := executeCustody(receipt) + if err != nil { + t.Fatal(err) + } + receiptBytes, err := os.ReadFile(result.Outputs["canonical"]) + if err != nil { + t.Fatal(err) + } + var r mpcceremony.TransferReceipt + if err := mpcceremony.UnmarshalCanonical(receiptBytes, &r); err != nil { + t.Fatal(err) + } + if err := mpcceremony.VerifyTransferReceipt(raw, handoff, r); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, filepath.Join(root, ref.Name), []byte("changed"), 0600) + receipt.OutDir = filepath.Join(root, "tampered-receipt") + if _, err := executeCustody(receipt); err == nil { + t.Fatal("receipt acknowledged different delivered bytes") + } + if _, err := os.Stat(receipt.OutDir); !os.IsNotExist(err) { + t.Fatal("failed receipt left a signing packet") + } +} diff --git a/cmd/mpc-ceremony/ops_guided.go b/cmd/mpc-ceremony/ops_guided.go index a94f30be..2a855f8a 100644 --- a/cmd/mpc-ceremony/ops_guided.go +++ b/cmd/mpc-ceremony/ops_guided.go @@ -135,8 +135,14 @@ func executeOpsSign(o OpsSignOptions) (CommandResult, error) { return CommandResult{}, errors.New("owner must review the exact record and explicitly supply --reviewed") } kind := mpcceremony.OperationalRecordType(o.RecordType) - if kind != mpcceremony.RecordEnrollment && kind != mpcceremony.RecordPublicWitness && kind != mpcceremony.RecordMirrorReceipt && kind != mpcceremony.RecordEvidenceBundle { - return CommandResult{}, errors.New("ops sign is restricted to enrollment, public-witness, mirror-receipt and fully verified evidence-bundle records") + switch kind { + case mpcceremony.RecordEnrollment, mpcceremony.RecordPublicWitness, mpcceremony.RecordMirrorReceipt: + case mpcceremony.RecordHandoff, mpcceremony.RecordReceipt, mpcceremony.RecordBeaconEvidence, mpcceremony.RecordEvidenceBundle: + if len(o.ReviewedSHA256) != 64 { + return CommandResult{}, errors.New("custody and aggregate evidence signing requires --reviewed-sha256 of the exact reviewed canonical bytes") + } + default: + return CommandResult{}, errors.New("unsupported operational signing record type") } canonical, record, trusted, err := loadBoundOperationalRecord(kind, o.RecordPath, o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile) if err != nil { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 3de33162..a3e046cd 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -84,6 +84,10 @@ func parseInvocation(args []string) (Invocation, error) { return parsePhase2(invocation, rest[1:]) case "finalize": return parseFinalize(invocation, rest[1:]) + case "replay": + options, err := parseReplay(rest[1:]) + invocation.Command, invocation.Options = CommandReplay, options + return invocation, wrapCommandError(err, "replay") case "audit": options, err := parseAudit(rest[1:]) invocation.Command, invocation.Options = CommandAudit, options @@ -432,6 +436,10 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)} } switch args[0] { + case "prepare-handoff", "prepare-receipt": + options, err := parseCustody(args[1:], args[0] == "prepare-receipt") + invocation.Command, invocation.Options = CommandOpsPrepareCustody, options + return invocation, wrapCommandError(err, "ops", args[0]) case "prepare-bundle": options, err := parseOpsPrepareBundle(args[1:]) invocation.Command, invocation.Options = CommandOpsPrepareBundle, options @@ -988,6 +996,10 @@ func parseFinalize(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &usageError{message: "missing finalize command", topic: []string{"finalize"}} } switch args[0] { + case "rehearsal-evidence": + options, err := parseRehearsalEvidence(args[1:]) + invocation.Command, invocation.Options = CommandRehearsalEvidence, options + return invocation, wrapCommandError(err, "finalize", "rehearsal-evidence") case "prepare": options, err := parsePrepareFinalization(args[1:]) invocation.Command, invocation.Options = CommandFinalizePrepare, options @@ -1051,6 +1063,21 @@ func parseCompleteFinalization(args []string) (FinalizeOptions, error) { return options, validateReplayOptions(options.Replay) } +func parseReplay(args []string) (AuditOptions, error) { + var options AuditOptions + fs := commandFlagSet("replay") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + addReplayFlags(fs, &options.Replay) + fs.StringVar(&options.CandidateBundleDir, "candidate-bundle", "", "signed candidate or released key directory") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := requireValues(pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), pathValue("--candidate-bundle", options.CandidateBundleDir)); err != nil { + return options, err + } + return options, validateReplayOptions(options.Replay) +} + func parseAudit(args []string) (AuditOptions, error) { var options AuditOptions fs := commandFlagSet("audit") diff --git a/cmd/mpc-ceremony/redaction_test.go b/cmd/mpc-ceremony/redaction_test.go index 626f24f7..326fcec5 100644 --- a/cmd/mpc-ceremony/redaction_test.go +++ b/cmd/mpc-ceremony/redaction_test.go @@ -75,3 +75,23 @@ func TestWriteDiagnosticRedactsByConstruction(t *testing.T) { t.Fatalf("writeDiagnostic did not mark the redaction: %q", out.String()) } } + +func TestOperationalGrammarStaysReadableWithoutExposingPaths(t *testing.T) { + args := []string{"ops", "sign", "--record-type", "receipt", "--related-record", "/private/handoff.json", "--signing-key", "/private/signing.hex"} + message := "ops sign receipt requires --related-record; open /private/handoff.json /private/signing.hex" + actual := redactCLIError(message, args) + for _, text := range []string{"ops sign receipt", "--related-record"} { + if !strings.Contains(actual, text) { + t.Fatalf("lost public grammar: %s", actual) + } + } + for _, text := range []string{"/private/handoff.json", "/private/signing.hex"} { + if strings.Contains(actual, text) { + t.Fatal("private path was not redacted") + } + } + unknown := redactCLIError("unknown arbitrary-secret", []string{"ops", "arbitrary-secret"}) + if strings.Contains(unknown, "arbitrary-secret") { + t.Fatal("unknown command exposed") + } +} diff --git a/cmd/mpc-ceremony/rehearsal_evidence.go b/cmd/mpc-ceremony/rehearsal_evidence.go new file mode 100644 index 00000000..e32814a0 --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal_evidence.go @@ -0,0 +1,143 @@ +// Generate a real proof for the repository's tiny rehearsal circuit and public +// golden vector. No application wallet material is accepted by this helper. +package main + +import ( + "bytes" + "encoding/hex" + "errors" + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend/groth16" + "github.com/consensys/gnark/frontend" + "golang.org/x/crypto/blake2b" + "math/big" + "os" + "path/filepath" + "proof-tool/internal/circuit/rehearsal" + "proof-tool/internal/mpcceremony" + "proof-tool/internal/prover" +) + +type RehearsalEvidenceOptions struct{ KeysDir, CoordinatorPublicKeyFile, CeremonyID, OutPath string } + +func parseRehearsalEvidence(args []string) (RehearsalEvidenceOptions, error) { + var o RehearsalEvidenceOptions + f := commandFlagSet("finalize rehearsal-evidence") + f.StringVar(&o.KeysDir, "keys-dir", "", "authenticated preliminary final keys") + f.StringVar(&o.CoordinatorPublicKeyFile, "coordinator-public-key-file", "", "separately trusted coordinator key") + f.StringVar(&o.CeremonyID, "ceremony-id", "", "expected signed ceremony ID") + f.StringVar(&o.OutPath, "out", "", "fresh public evidence file") + if err := parseFlags(f, args); err != nil { + return o, err + } + return o, requireValues(pathValue("--keys-dir", o.KeysDir), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), value("--ceremony-id", o.CeremonyID), pathValue("--out", o.OutPath)) +} +func executeRehearsalEvidence(o RehearsalEvidenceOptions) (CommandResult, error) { + if err := generateRehearsalEvidence(o); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: o.CeremonyID, Summary: "Generated and verified a real tiny-circuit proof using public golden inputs; not a production ownership proof", Outputs: map[string]string{"public_evidence": o.OutPath}}, nil +} +func generateRehearsalEvidence(o RehearsalEvidenceOptions) error { + key, err := os.ReadFile(o.CoordinatorPublicKeyFile) + if err != nil { + return err + } + pre, err := mpcceremony.VerifyPreliminaryFinalKeys(o.KeysDir, string(key)) + if err != nil { + return err + } + if pre.CeremonyID != o.CeremonyID { + return errors.New("ceremony mismatch") + } + if pre.Circuit.Constraints != 5 || pre.Circuit.R1CS.Digest.SHA256 != "sha256:1cbaefe7d52545efae5a9033f6fd381b667ec305da58fb84065a79438c5161ab" { + return errors.New("only the exact pinned five-constraint rehearsal circuit is permitted") + } + ccs, err := mpcceremony.ReadR1CSFile(filepath.Join(o.KeysDir, pre.ConstraintSystem.Name), pre.Circuit) + if err != nil { + return err + } + credential, err := hex.DecodeString(mpcceremony.GoldenPublicCredentialHex) + if err != nil { + return err + } + destination, err := hex.DecodeString(mpcceremony.GoldenPublicDestinationHex) + if err != nil { + return err + } + preimage := append([]byte(mpcceremony.DestinationPublicDomain), credential...) + preimage = append(preimage, destination...) + digest := blake2b.Sum256(preimage) + reversed := bytes.Clone(digest[:]) + for l, r := 0, len(reversed)-1; l < r; l, r = l+1, r-1 { + reversed[l], reversed[r] = reversed[r], reversed[l] + } + scalar := new(big.Int).SetBytes(reversed) + scalar.Mod(scalar, ecc.BLS12_381.ScalarField()) + // The released rehearsal circuit proves X^3 = Pub (the older workflow test + // helper uses a different tiny circuit). This fixed public golden scalar is + // a cubic residue. In this field r-1 = 3*q with gcd(3,q)=1, so exponentiating + // by 3^-1 mod q gives a publicly computable satisfying rehearsal witness. + field := ecc.BLS12_381.ScalarField() + q := new(big.Int).Sub(field, big.NewInt(1)) + q.Div(q, big.NewInt(3)) + exponent := new(big.Int).ModInverse(big.NewInt(3), q) + if exponent == nil { + return errors.New("unexpected scalar-field cube subgroup") + } + cubeRoot := new(big.Int).Exp(scalar, exponent, field) + if new(big.Int).Exp(cubeRoot, big.NewInt(3), field).Cmp(scalar) != 0 { + return errors.New("public golden input is not a cubic residue") + } + witness, err := frontend.NewWitness(&rehearsal.Circuit{X: cubeRoot, Pub: scalar}, field) + if err != nil { + return err + } + pk, err := prover.LoadPK(filepath.Join(o.KeysDir, mpcceremony.NativeProvingKeyFile)) + if err != nil { + return err + } + vk, err := prover.LoadVK(filepath.Join(o.KeysDir, mpcceremony.NativeVerifyingKeyFile)) + if err != nil { + return err + } + proof, err := groth16.Prove(ccs.R1CS, pk, witness) + if err != nil { + return err + } + public, err := witness.Public() + if err != nil { + return err + } + if err = groth16.Verify(proof, vk, public); err != nil { + return err + } + cardano, format, err := prover.SerializeCardanoProof(proof) + if err != nil { + return err + } + vkbytes, err := os.ReadFile(filepath.Join(o.KeysDir, mpcceremony.CardanoVKBytesFile)) + if err != nil { + return err + } + evidence := mpcceremony.PublicFinalizationEvidence{Schema: mpcceremony.PublicEvidenceSchema, CeremonyID: o.CeremonyID, Fixture: mpcceremony.PublicEvidenceFixture, CredentialHex: hex.EncodeToString(credential), DestinationHex: hex.EncodeToString(destination), PublicInputDigestHex: hex.EncodeToString(digest[:]), CardanoProofHex: hex.EncodeToString(cardano), CardanoProofFormat: format, CardanoProofRawDigest: mpcceremony.NewDigest(cardano), CardanoVerifyingKey: mpcceremony.ArtifactRef{Name: mpcceremony.CardanoVKBytesFile, Digest: mpcceremony.NewDigest(vkbytes)}} + if err = evidence.Validate(); err != nil { + return err + } + data, err := mpcceremony.MarshalCanonical(evidence) + if err != nil { + return err + } + f, err := os.OpenFile(o.OutPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + if _, err = f.Write(data); err != nil { + return err + } + if err = f.Sync(); err != nil { + return err + } + return nil +} diff --git a/cmd/mpc-ceremony/replay_test.go b/cmd/mpc-ceremony/replay_test.go new file mode 100644 index 00000000..fb576c9f --- /dev/null +++ b/cmd/mpc-ceremony/replay_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "strings" + "testing" +) + +func TestPublicReplayRequiresEvidenceButNoSigningIdentity(t *testing.T) { + args := []string{"replay", "--ceremony", "ceremony.json", "--ceremony-signature", "ceremony.sig", "--coordinator-public-key-file", "coordinator.pub", "--candidate-bundle", "release", "--transcript-root", "transcript"} + for _, phase := range []string{"phase1", "phase2"} { + for _, artifact := range []string{"chain", "close", "beacon"} { + args = append(args, "--"+phase+"-"+artifact, "record.json", "--"+phase+"-"+artifact+"-signature", "record.sig") + } + } + args = append(args, "--phase1-seal", "seal.json", "--phase1-seal-signature", "seal.sig") + invocation, err := parseInvocation(args) + if err != nil { + t.Fatal(err) + } + if invocation.Command != CommandReplay { + t.Fatal(invocation.Command) + } + options := invocation.Options.(AuditOptions) + if options.AuditorSigningKey != "" || options.AuditorID != "" || options.SignatureOutPath != "" { + t.Fatal("public replay requested signing state") + } + for _, flag := range []string{"--auditor-signing-key", "--auditor-id", "--out", "--audit-signature"} { + if _, err := parseInvocation(append(append([]string{}, args...), flag, "forbidden")); err == nil { + t.Fatalf("accepted %s", flag) + } + } + if _, err := parseInvocation(args[:len(args)-2]); err == nil || !strings.Contains(err.Error(), "phase1-seal-signature") { + t.Fatalf("missing seal signature accepted: %v", err) + } +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index c0623c53..4c0d6b26 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -31,9 +31,12 @@ const ( CommandPhase2Verify Command = "phase2 verify" CommandPhase2Close Command = "phase2 close" CommandPhase2Beacon Command = "phase2 beacon" + CommandOpsPrepareCustody Command = "ops prepare-custody" CommandFinalizePrepare Command = "finalize prepare" + CommandRehearsalEvidence Command = "finalize rehearsal-evidence" CommandFinalizeComplete Command = "finalize complete" CommandAudit Command = "audit" + CommandReplay Command = "replay" CommandReleaseSign Command = "release sign" CommandReleaseVerify Command = "release verify" CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" @@ -416,6 +419,7 @@ type ReplayOptions struct { } type CommandResult struct { + ReleaseManifestSHA256 string `json:"release_manifest_sha256,omitempty"` Schema string `json:"schema"` OK bool `json:"ok"` Command Command `json:"command"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index c5a48a38..911b9ed2 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -48,6 +48,8 @@ Commands: phase2 beacon Record signed post-closure beacon evidence finalize prepare Replay both phases and publish preliminary final keys finalize complete Verify external public evidence and create candidate + finalize rehearsal-evidence Generate a real proof for the tiny rehearsal circuit + replay Publicly replay both phases without signing audit Independently replay and audit ceremony artifacts release sign Sign an audited release manifest release verify Verify release and ceremony coherence @@ -352,6 +354,14 @@ Records the distinct Phase 2 post-closure beacon evidence used by finalize. "finalize": `Usage: mpc-ceremony finalize prepare [FLAGS] mpc-ceremony finalize complete [FLAGS] +`, + "finalize rehearsal-evidence": `Usage: + mpc-ceremony finalize rehearsal-evidence --keys-dir DIR \ + --coordinator-public-key-file FILE --ceremony-id ID --out FILE + +Authenticates preliminary keys and checks the exact supported tiny circuit. +Generates and verifies a real proof using public golden inputs. Never accepts +wallet material, overwrites evidence, or produces a production ownership proof. `, "finalize prepare": `Usage: mpc-ceremony finalize prepare --ceremony FILE --ceremony-signature FILE \ @@ -376,6 +386,16 @@ Replays both phases again, verifies the canonical external public proof against the replayed final VK, and creates the coordinator-signed but unsigned-for-release candidate. It accepts only the public evidence artifact. Release signing remains a separate post-audit step. +`, + "replay": `Usage: + mpc-ceremony replay --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY [REPLAY EVIDENCE FLAGS] \ + --candidate-bundle DIR +` + replayFlagsHelp + ` +Independently compiles the signed circuit and replays both phases, checking +randomness, final native keys, Cardano export and public proof evidence. +Requires no private key and writes no signed audit. Release signatures and +production approval are checked separately with release verify and decision verify. `, "audit": `Usage: mpc-ceremony audit --ceremony FILE \ @@ -509,20 +529,47 @@ Derives the canonical enrollment from the authenticated definition and the owner's public identity and disclosure. Internal role indices are derived; external witness/mirror indices are assigned through the coordination channel. No private key is read. Share the entire public export with the disclosure. +`, + "ops prepare-handoff": `Usage: + mpc-ceremony ops prepare-handoff --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root DIR \ + --chain FILE --chain-signature FILE --participant-id ID \ + --direction outbound|return [--candidate-dir DIR] --out-dir FRESH_DIR + +Derives the next turn from the signed current chain. Outbound names its input; +return hashes the completed candidate including cleanup acknowledgment. Creates +an unsigned canonical packet with the actual current time and one-hour expiry. +Review and sign before sending. Preserve an existing packet instead of overwriting +it. A late-created handoff cannot replace a missing earlier custody event. +`, + "ops prepare-receipt": `Usage: + mpc-ceremony ops prepare-receipt --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root RECEIVED_FILES_ROOT \ + --handoff FILE --handoff-signature FILE --sender-public-key-file KEY \ + --out-dir FRESH_DIR + +First receive the exact named public files into their logical paths under the +received-files root. Verifies the sender signature and every received file digest, +then prepares a receipt at the actual current time. Review and sign it as the +named recipient. No network transfer or physical-air-gap claim is made. `, "ops sign": `Usage: mpc-ceremony ops sign --record-type TYPE --record CANONICAL_FILE \ --ceremony FILE --ceremony-signature FILE --coordinator-public-key-file KEY \ --signing-key OWN_KEY_FILE --reviewed [--reviewed-sha256 HEX] --out FRESH_SIGNATURE_JSON -Owner signing for enrollment, public-witness, mirror-receipt or evidence-bundle. +Offline owner signing for enrollment, public-witness, mirror-receipt, handoff, +receipt, beacon-evidence and evidence-bundle records. Bundle signing additionally requires --evidence-root DIR and verifies every -referenced operational record before reading the coordinator's signing key. +referenced operational record before reading the coordinator’s signing key. Authenticates the ceremony, canonical record and owner key. Review the exact record and associated disclosure/observations before --reviewed. This signs your claim; it does not independently observe publication or prove independence. Enrollment signing requires its matching disclosure tree beside the record. -The optional reviewed hash binds signing to bytes previously shown by a helper. +The reviewed hash binds signing to bytes previously shown by a helper. It is +required for handoff, receipt, beacon-evidence and evidence-bundle signing. +Run ops verify afterwards; receipts require --related-record and bundles require +--evidence-root. A signature alone does not verify a complete ceremony. `, "ops prepare-bundle": `Usage: mpc-ceremony ops prepare-bundle --ceremony FILE --ceremony-signature FILE \ diff --git a/docs/ceremony-custody-workflow.md b/docs/ceremony-custody-workflow.md new file mode 100644 index 00000000..7e09a009 --- /dev/null +++ b/docs/ceremony-custody-workflow.md @@ -0,0 +1,78 @@ +# Supported custody and tiny-proof commands + +These commands close the gaps found in the September 2026 same-operator rehearsal. +They do not change the signed protocol, waive operational evidence, prove physical +independence, or authorize a production release. + +## Each participant turn + +1. Before computation, the coordinator runs `ops prepare-handoff` with the exact + current signed chain, next participant, `--direction outbound`, and a fresh + output directory. The command derives the next index, predecessor, software, + circuit, input payload and current creation/expiry times. +2. The coordinator reviews `canonical.json`, then runs `ops sign --record-type + handoff --reviewed --reviewed-sha256 HEX` with their own key and a fresh + signature output. The hash is the SHA-256 of the exact reviewed canonical bytes. +3. Transfer the canonical handoff, signature and named public payload to the + participant through the agreed channel. Keep credentials separate. +4. The participant places received payloads at their named relative paths under + their received-files root. `ops prepare-receipt` verifies the sender signature + and hashes every retained file, then records the actual current receipt time. + Review/sign the receipt as `--record-type receipt`, and return its exact bytes + and signature to the coordinator before computing. +5. Verify the receipt with `ops verify --record-type receipt --related-record + ORIGINAL_HANDOFF`, the recipient public key and their receipt signature. +6. Compute using the approved contributor supervisor. Retain the completed public + candidate and cleanup acknowledgment after container removal. +7. Before acceptance, the participant repeats handoff preparation with `--direction + return --candidate-dir DIR`. It binds the same pre-acceptance chain and the five + public candidate files, including cleanup acknowledgment and signatures. +8. The coordinator receives those named public files, prepares and signs the return + receipt, retains both directions of custody evidence, and only then accepts. + +Use a fresh directory per turn and direction. Interrupted packets are retained; +these commands never overwrite them. Handoffs expire after one hour. A missing +historical handoff cannot be repaired by creating or backdating a later receipt. +The final operational verifier still checks complete custody chronology against +accepted contributions; merely signing one record does not establish that result. + +Run `mpc-ceremony help ops prepare-handoff` and `help ops prepare-receipt` for the +complete flags. Receiver preparation performs no network transfer itself and cannot +prove how the files crossed between machines. Signing belongs on the key-owning +station; container network isolation does not physically disconnect its host. + +## Aggregate operational evidence + +`ops sign` also supports canonical `beacon-evidence` and `evidence-bundle` records. +Both require explicit review and the exact reviewed SHA-256. They retain the same +signed-definition, canonical-record and owner-key checks as other record types. +Run `ops verify --record-type evidence-bundle --evidence-root DIR` afterwards; +this performs the full evidence verification and is mandatory before final release. +Unsupported record types, changed reviewed bytes, wrong owner keys and existing +signature outputs fail closed. + +## Tiny rehearsal proof + +After `finalize prepare`, run: + +```sh +mpc-ceremony finalize rehearsal-evidence \ + --keys-dir /work/preliminary \ + --coordinator-public-key-file /trust/coordinator-public-key.hex \ + --ceremony-id EXPECTED_SIGNED_CEREMONY_ID \ + --out /work/public-finalization-evidence.json +``` + +This authenticates the preliminary keys under the separately trusted coordinator +key, requires the exact supported five-constraint rehearsal circuit, and generates +and verifies a real proof using the repository's public golden input. It accepts +no wallet secret inputs. The output feeds `finalize complete`, which independently +replays the ceremony and validates the proof. Production circuits must use their +own compatible public-evidence generation process. + +## Release sequencing + +Publish these commands through the protected proof-tool release workflow. Relay +must then pin that exact reviewed release and its checksums in both role images. +Existing frozen ceremonies retain their previous tool/software/workflow pins. +Source tests or a local binary alone do not activate a new production release. diff --git a/docs/public-replay.md b/docs/public-replay.md new file mode 100644 index 00000000..b6082c90 --- /dev/null +++ b/docs/public-replay.md @@ -0,0 +1,27 @@ +# Unsigned public ceremony replay + +`mpc-ceremony replay` accepts the same ceremony trust, replay evidence, and candidate +paths as `audit`, but no auditor identity, private key, timestamp, or output signature. +It independently compiles the signed circuit, replays both phases, validates the beacon +and seal bindings, and compares final native keys, Cardano export, and public proof +evidence. It calls the same replay/comparison helper as signed audits. It writes no +protocol assertion and does not waive any running-software or signed-policy checks. +Use `mpc-ceremony replay --help` for the complete file flags. + +`release verify` remains a separate check of the signed release and bundled evidence; +`decision verify` checks production approval. Their JSON results include +`release_manifest_sha256` so archive tools can require a GO decision for the exact +release they have verified, not another release from the same ceremony. + +The installed verifier must satisfy the frozen definition's exact software binding. +This new entry point does not authorize newer binaries for old ceremonies. Trust keys +may come from the website publishing the archive when that is the reader's selected +trust source. Passing checks do not prove secret deletion, offline execution, or human +independence, and unsigned replay must never be described as an enrolled signed audit. + +The signed lifecycle helper exercises unsigned replay before signed audits and rejects +a tampered candidate signature. Run `go test ./cmd/mpc-ceremony ./internal/mpcceremony` +with the normal repository vendor preparation. Builds used by the signed workflow need +real Go VCS metadata: use a full checkout if the local Go version cannot stamp linked +Git worktrees. Do not disable software identity verification to accommodate missing +build metadata. diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 01223bcb..e0617307 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -88,9 +88,10 @@ type VerifyReleaseOptions struct { } type VerifyReleaseResult struct { - Manifest *artifact.KeyManifest - Transcript FinalTranscript - Candidate CandidateMetadata + ManifestSHA256 string + Manifest *artifact.KeyManifest + Transcript FinalTranscript + Candidate CandidateMetadata } // Audit independently replays both phases from explicit immutable paths, @@ -135,27 +136,7 @@ func Audit(options AuditOptions) (*AuditResult, error) { if !options.AuditedAt.After(candidateTime) { return nil, errors.New("audited_at must strictly postdate candidate finalization") } - phase2Seal, err := loadCandidatePhase2Seal(replay.definition, candidate, options.CandidateDir) - if err != nil { - return nil, err - } - if err := ValidateSeal(replay.phase2Close, replay.phase2Beacon, phase2Seal); err != nil { - return nil, fmt.Errorf("candidate phase2 seal: %w", err) - } - replay.phase2Seal = phase2Seal - replayed, err := replayAll(options.Circuit, replay, options.Replay) - if err != nil { - return nil, err - } - if err := compareCandidateToReplay( - options.Circuit, - replay, - replayed.pk, - replayed.vk, - candidate, - options.CandidateDir, - options.AuditedAt, - ); err != nil { + if err := verifyCandidateReplay(options.Circuit, &replay, options.Replay, candidate, options.CandidateDir); err != nil { return nil, err } replayRoot, err := replayRootSHA256(candidate) @@ -197,6 +178,49 @@ func Audit(options AuditOptions) (*AuditResult, error) { return &AuditResult{Record: record, RecordPath: options.OutPath, SignaturePath: options.SignatureOutPath}, nil } +// ReplayCandidate verifies the complete candidate without an enrolled identity, +// a private key, or writing an audit assertion. The supplied circuit must be +// independently compiled by the trusted caller, as with Audit. +func ReplayCandidate(paths ReplayPaths, circuit *CompiledCircuit, candidateDir string) (string, error) { + if circuit == nil || circuit.R1CS == nil { + return "", errors.New("independently compiled circuit is required") + } + replay, err := loadReplay(paths) + if err != nil { + return "", err + } + if err := VerifyRunningSoftwareForMode(replay.definition.Software, replay.definition.Mode); err != nil { + return "", err + } + if err := ValidateCircuitBinding(circuit, replay.definition.Circuit); err != nil { + return "", err + } + candidate, _, err := verifyCandidate(replay.definition, replay.definitionRef, candidateDir) + if err != nil { + return "", err + } + if err := verifyCandidateReplay(circuit, &replay, paths, candidate, candidateDir); err != nil { + return "", err + } + return replay.definition.CeremonyID, nil +} + +func verifyCandidateReplay(circuit *CompiledCircuit, replay *loadedReplay, paths ReplayPaths, candidate CandidateMetadata, dir string) error { + phase2Seal, err := loadCandidatePhase2Seal(replay.definition, candidate, dir) + if err != nil { + return err + } + if err := ValidateSeal(replay.phase2Close, replay.phase2Beacon, phase2Seal); err != nil { + return fmt.Errorf("candidate phase2 seal: %w", err) + } + replay.phase2Seal = phase2Seal + replayed, err := replayAll(circuit, *replay, paths) + if err != nil { + return err + } + return compareCandidateToReplay(circuit, *replay, replayed.pk, replayed.vk, candidate, dir) +} + func compareCandidateToReplay( circuit *CompiledCircuit, replay loadedReplay, @@ -204,7 +228,6 @@ func compareCandidateToReplay( vk groth16.VerifyingKey, candidate CandidateMetadata, dir string, - auditedAt time.Time, ) error { loadedCCS, err := ReadR1CSFile(filepath.Join(dir, candidate.ConstraintSystem.Name), replay.definition.Circuit) if err != nil { @@ -658,7 +681,11 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { ); err != nil { return nil, err } - return &VerifyReleaseResult{Manifest: manifest, Transcript: transcript, Candidate: candidate}, nil + manifestRef, err := artifactRefForFile(keybundle.ManifestFile, filepath.Join(options.KeysDir, keybundle.ManifestFile)) + if err != nil { + return nil, err + } + return &VerifyReleaseResult{Manifest: manifest, Transcript: transcript, Candidate: candidate, ManifestSHA256: manifestRef.Digest.SHA256}, nil } func verifyCandidate( diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index f0b2b7d2..2956f21d 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -697,6 +697,25 @@ func run(outputRoot, operationalEvidenceHelper string) error { return fmt.Errorf("complete finalization: %w", err) } + if id, err := mpcceremony.ReplayCandidate(replay, circuit, candidateDir); err != nil || id != initialized.Definition.CeremonyID { + return fmt.Errorf("unsigned public replay failed: %s: %v", id, err) + } + + candidateSignature := filepath.Join(candidateDir, mpcceremony.CandidateSignatureFile) + originalSignature, err := os.ReadFile(candidateSignature) + if err != nil { + return err + } + if err := os.WriteFile(candidateSignature, []byte("tampered"), 0600); err != nil { + return err + } + if _, err := mpcceremony.ReplayCandidate(replay, circuit, candidateDir); err == nil { + return errors.New("unsigned replay accepted tampered candidate signature") + } + if err := os.WriteFile(candidateSignature, originalSignature, 0600); err != nil { + return err + } + auditDir := filepath.Join(outputRoot, "audits") if err := os.Mkdir(auditDir, 0o700); err != nil { return err From 97eed9698f3d5c8b840821098286f9f481157c92 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 11 Sep 2026 00:57:03 +0900 Subject: [PATCH 56/64] Allow single ceremony observers (#25) --- cmd/mpc-ceremony/journey_inspection.go | 2 +- cmd/mpc-ceremony/journey_inspection_test.go | 2 +- cmd/mpc-ceremony/ops_bundle.go | 12 ++--- cmd/mpc-ceremony/usage.go | 6 +-- docs/single-observer-minimum.md | 21 +++++++++ internal/mpcceremony/adversarial_test.go | 8 +++- internal/mpcceremony/audit.go | 6 +-- internal/mpcceremony/chain.go | 4 +- internal/mpcceremony/decision.go | 20 ++++---- internal/mpcceremony/decision_test.go | 2 +- internal/mpcceremony/definition.go | 4 +- internal/mpcceremony/operational.go | 4 +- internal/mpcceremony/operational_bundle.go | 10 ++-- .../mpcceremony/operational_bundle_test.go | 33 ++++++++++++- internal/mpcceremony/operational_prepare.go | 10 ++-- internal/mpcceremony/single_auditor_test.go | 46 +++++++++++++++++++ internal/mpcceremony/workflow.go | 4 +- 17 files changed, 146 insertions(+), 48 deletions(-) create mode 100644 docs/single-observer-minimum.md create mode 100644 internal/mpcceremony/single_auditor_test.go diff --git a/cmd/mpc-ceremony/journey_inspection.go b/cmd/mpc-ceremony/journey_inspection.go index 45be9da4..b38dee93 100644 --- a/cmd/mpc-ceremony/journey_inspection.go +++ b/cmd/mpc-ceremony/journey_inspection.go @@ -44,7 +44,7 @@ type JourneyInspection struct { } func inspectDefinitionJourney(d mpcceremony.CeremonyDefinition) *DefinitionJourneyInspection { - r := &DefinitionJourneyInspection{Schema: "proof-tool-mpc-definition-journey-v1", MinimumPublicWitnesses: 2, MinimumMirrorsPerAcceptedHead: 2, ObserverRequirementSource: "operational-bundle verifier minimum; an agreed witness quorum can require more"} + r := &DefinitionJourneyInspection{Schema: "proof-tool-mpc-definition-journey-v1", MinimumPublicWitnesses: 1, MinimumMirrorsPerAcceptedHead: 1, ObserverRequirementSource: "operational-bundle verifier minimum; an agreed witness quorum can require more"} r.RequiredEnrollments = append(r.RequiredEnrollments, ExpectedEnrollmentInspection{mpcceremony.EnrollmentCoordinator, 1, d.Coordinator}, ExpectedEnrollmentInspection{mpcceremony.EnrollmentReleaseSigner, 1, d.ReleaseSigner}) for n, id := range d.Auditors { r.RequiredEnrollments = append(r.RequiredEnrollments, ExpectedEnrollmentInspection{mpcceremony.EnrollmentAuditor, n + 1, id}) diff --git a/cmd/mpc-ceremony/journey_inspection_test.go b/cmd/mpc-ceremony/journey_inspection_test.go index c6aa59fd..60ee7111 100644 --- a/cmd/mpc-ceremony/journey_inspection_test.go +++ b/cmd/mpc-ceremony/journey_inspection_test.go @@ -27,7 +27,7 @@ func TestDefinitionJourneyProjectsEveryRequiredEnrollment(t *testing.T) { t.Fatal("incorrect participant assignment") } } - if j.MinimumPublicWitnesses != 2 || j.MinimumMirrorsPerAcceptedHead != 2 || j.ObserverRequirementSource == "" { + if j.MinimumPublicWitnesses != 1 || j.MinimumMirrorsPerAcceptedHead != 1 || j.ObserverRequirementSource == "" { t.Fatal("operational verifier minimums omitted") } d.Auditors[0].DisplayName = "changed" diff --git a/cmd/mpc-ceremony/ops_bundle.go b/cmd/mpc-ceremony/ops_bundle.go index 32dbe4a4..a34b44ab 100644 --- a/cmd/mpc-ceremony/ops_bundle.go +++ b/cmd/mpc-ceremony/ops_bundle.go @@ -23,12 +23,12 @@ func parseOpsPrepareBundle(args []string) (OpsPrepareBundleOptions, error) { addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) f.StringVar(&o.EvidenceRoot, "evidence-root", "", "public-only evidence directory; never a keys or credentials directory") f.StringVar(&o.OutDir, "out-dir", "", "evidence-root/operational; existing evidence is preserved, bundle outputs must be fresh") - f.UintVar(&o.WitnessQuorum, "witness-quorum", 2, "agreed minimum public witnesses per phase (2-32)") + f.UintVar(&o.WitnessQuorum, "witness-quorum", 1, "agreed minimum public witnesses per phase (1-32)") if err := parseFlags(f, args); err != nil { return o, err } - if o.WitnessQuorum < 2 || o.WitnessQuorum > 32 { - return o, errors.New("witness quorum must be between 2 and 32") + if o.WitnessQuorum < 1 || o.WitnessQuorum > 32 { + return o, errors.New("witness quorum must be between 1 and 32") } return o, requireValues(pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--evidence-root", o.EvidenceRoot), pathValue("--out-dir", o.OutDir)) } @@ -38,8 +38,8 @@ func executeOpsPrepareBundle(o OpsPrepareBundleOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - if o.WitnessQuorum < 2 || o.WitnessQuorum > 32 { - return CommandResult{}, errors.New("witness quorum must be between 2 and 32") + if o.WitnessQuorum < 1 || o.WitnessQuorum > 32 { + return CommandResult{}, errors.New("witness quorum must be between 1 and 32") } prepared, err := mpcceremony.PrepareOperationalEvidence(trusted.Definition, o.EvidenceRoot, time.Now().UTC().Format(time.RFC3339Nano)) if err != nil { @@ -47,7 +47,7 @@ func executeOpsPrepareBundle(o OpsPrepareBundleOptions) (CommandResult, error) { } for index, phase := range []*mpcceremony.PhaseOperationalEvidence{&prepared.Bundle.Phase1, &prepared.Bundle.Phase2} { phase.PublicWitnessQuorum = uint8(o.WitnessQuorum) - if o.WitnessQuorum > 2 && len(phase.PublicWitnessReceipts) < int(o.WitnessQuorum) { + if o.WitnessQuorum > 1 && len(phase.PublicWitnessReceipts) < int(o.WitnessQuorum) { prepared.Missing = append(prepared.Missing, fmt.Sprintf("phase%d: agreed witness quorum is %d, found %d records", index+1, o.WitnessQuorum, len(phase.PublicWitnessReceipts))) } } diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 911b9ed2..27c6b2df 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -425,7 +425,7 @@ Release authenticity is separate from MPC contribution identity. --release-signing-key KEY --signature-key-id ID \ --released-at RFC3339_UTC --release-dir FRESH_DIR - Requires at least two distinct enrolled auditors plus the coordinator-signed + Requires at least one enrolled auditor plus the coordinator-signed Phase 1 and Phase 2 operational bundle. Each phase must contain a valid public witness quorum and matching multi-relay beacon responses. The candidate is never mutated; all verified evidence is atomically published into a fresh @@ -574,7 +574,7 @@ Run ops verify afterwards; receipts require --related-record and bundles require "ops prepare-bundle": `Usage: mpc-ceremony ops prepare-bundle --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --evidence-root PUBLIC_DIR --out-dir PUBLIC_DIR/operational \ - [--witness-quorum 2] + [--witness-quorum 1] Discovers bounded public JSON and signatures; never point it at private keys or credentials. Reports missing or conflicting evidence by phase and turn. Keep @@ -582,7 +582,7 @@ original relative paths when collecting public records from their owners. The operational directory may exist; existing evidence is preserved. Bundle, signature and signing-request files must not already exist. Interrupted output is retained for inspection, never automatically overwritten. -Set witness-quorum to your agreed minimum per phase (2-32), not a lower value +Set witness-quorum to your agreed minimum per phase (1-32), not a lower value chosen to fit the available receipts. If complete, independently verifies all referenced evidence and exports an UNSIGNED canonical bundle and signing request. It does not invent records, diff --git a/docs/single-observer-minimum.md b/docs/single-observer-minimum.md new file mode 100644 index 00000000..a1500467 --- /dev/null +++ b/docs/single-observer-minimum.md @@ -0,0 +1,21 @@ +# Single-observer minimums (unreleased) + +Both rehearsal and production require at least one enrolled auditor, one public +witness per phase, and one mirror operator providing a signed receipt for every +accepted contribution. The same witness and mirror may serve both phases. A +higher explicitly selected witness quorum remains binding. Zero is rejected. + +Release signing requires at least one verified passing transcript audit. +Production decisions also require at least one distinct external audit signoff; +this is a separate report requirement, not evidence supplied by a mirror or +witness. Every supplied report is still validated, including additional reports. + +Production still requires at least two participants per phase, every scheduled +contribution, distinct signing identities and the existing independence checks. +Beacon lead times and multi-relay observation requirements are unchanged. + +This policy changes verifier behavior and needs a new proof-tool release. Existing +ceremonies must continue using their approved proof-tool build. The companion CLI +must pin the new release and advertise the new `two-phase-v2` ruleset (version 2); +Tessera must provision that exact compatible CLI release before activating it. +Do not reinterpret old ceremonies using a newer verifier. diff --git a/internal/mpcceremony/adversarial_test.go b/internal/mpcceremony/adversarial_test.go index c843b0b5..5d95222c 100644 --- a/internal/mpcceremony/adversarial_test.go +++ b/internal/mpcceremony/adversarial_test.go @@ -1087,7 +1087,7 @@ func TestPinnedQuicknetBeaconVerificationRejectsMalformedOrForgedEvidence(t *tes } } -func TestReleaseRequiresTwoExactChronologicalIndependentAudits(t *testing.T) { +func TestReleaseRequiresExactChronologicalIndependentAudits(t *testing.T) { definition := adversarialDefinition(t) candidate := adversarialCandidate(t, definition) candidateBytes, err := MarshalCanonical(candidate) @@ -1114,6 +1114,12 @@ func TestReleaseRequiresTwoExactChronologicalIndependentAudits(t *testing.T) { "2026-07-23T13:02:00Z", outputs, ) + if refs, _, err := verifyPassingAudits(definition, candidate, []AuditArtifact{first}); err != nil || len(refs) != 1 { + t.Fatalf("one exact signed audit rejected: refs=%d err=%v", len(refs), err) + } + if _, _, err := verifyPassingAudits(definition, candidate, nil); err == nil { + t.Fatal("zero signed audits accepted") + } refs, latest, err := verifyPassingAudits( definition, candidate, diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index e0617307..e4540807 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -297,7 +297,7 @@ func compareCandidateToReplay( return nil } -// SignRelease validates at least two distinct, enrolled, signed passing +// SignRelease validates at least one enrolled, signed passing // audits, assembles the final setup transcript and key manifest without // replacing candidate files, then signs the exact manifest with the distinct // pre-existing release key. @@ -897,8 +897,8 @@ func verifyPassingAudits( candidate CandidateMetadata, inputs []AuditArtifact, ) ([]ArtifactRef, time.Time, error) { - if len(inputs) < 2 { - return nil, time.Time{}, errors.New("at least two independently signed audit reports are required") + if len(inputs) < 1 { + return nil, time.Time{}, errors.New("at least one independently signed audit report is required") } replayRoot, err := replayRootSHA256(candidate) if err != nil { diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index dfb4e207..74c5e7c1 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -1223,8 +1223,8 @@ func (r FinalTranscript) validate(requireID bool) error { if r.Phase2.Phase != Phase2 { return errors.New("phase2 summary has wrong phase") } - if len(r.Audits) < 2 { - return errors.New("final transcript requires at least two independent audit artifacts") + if len(r.Audits) < 1 { + return errors.New("final transcript requires at least one independent audit artifact") } if err := validateArtifactList("audits", r.Audits, MaxParticipants); err != nil { return err diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index c51f8712..8f748450 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -491,14 +491,10 @@ func (d ProductionDecision) Validate() error { if err := d.OperationalEvidence.Validate(); err != nil { return fmt.Errorf("operational_evidence: %w", err) } - // 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)) + // One is the floor, not the ceiling. Validate every supplied audit; + // additional auditors remain supported and must use distinct identities. + if len(d.Audits) < 1 { + return fmt.Errorf("production decision requires at least one audit, got %d", len(d.Audits)) } auditKeyIDs := make(map[string]struct{}, len(d.Audits)) for index, audit := range d.Audits { @@ -513,8 +509,8 @@ func (d ProductionDecision) Validate() error { } auditKeyIDs[audit.AuditorKeyID] = struct{}{} } - if len(d.ExternalAudits) < 2 { - return fmt.Errorf("production decision requires at least two external audits, got %d", len(d.ExternalAudits)) + if len(d.ExternalAudits) < 1 { + return fmt.Errorf("production decision requires at least one external audit, got %d", len(d.ExternalAudits)) } externalFingerprints := make(map[string]struct{}, len(d.ExternalAudits)) for index, external := range d.ExternalAudits { @@ -1270,8 +1266,8 @@ 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 +// Every named auditor is required, not just the minimum: the decision accepts +// one 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 { diff --git a/internal/mpcceremony/decision_test.go b/internal/mpcceremony/decision_test.go index 5276f3b6..18f1e6a2 100644 --- a/internal/mpcceremony/decision_test.go +++ b/internal/mpcceremony/decision_test.go @@ -260,7 +260,7 @@ func TestProductionDecisionHashesTheExactReleaseTree(t *testing.T) { }) } -func TestProductionDecisionRequiresTwoDistinctExternalAuditSignoffs(t *testing.T) { +func TestProductionDecisionRequiresDistinctExternalAuditSignoffs(t *testing.T) { fixture := newProductionDecisionFixture(t, DecisionGO) value := fixture.decision value.DecisionID = "" diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index 4e75becf..0365d82c 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -236,8 +236,8 @@ func (d CeremonyDefinition) validate(requireID bool) error { if d.ReleaseSigner.ID == d.Coordinator.ID || d.ReleaseSigner.KeyID == d.Coordinator.KeyID { return errors.New("release signer must be distinct from coordinator") } - if len(d.Auditors) < 2 { - return errors.New("at least two independent auditors are required") + if len(d.Auditors) < 1 { + return errors.New("at least one independent auditor is required") } if len(d.Auditors) > MaxAuditors { return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index f7344c21..659cf7d3 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -812,8 +812,8 @@ func VerifyPublicWitnessQuorum( receipts []SignedPublicWitness, minimum int, ) error { - if minimum < 2 { - return errors.New("public witness quorum minimum must be at least 2") + if minimum < 1 { + return errors.New("public witness quorum minimum must be at least 1") } if len(receipts) < minimum { return fmt.Errorf("have %d public witness receipts, need %d", len(receipts), minimum) diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index 842e8752..9e7b7f8c 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -71,8 +71,8 @@ func (e AcceptedHeadOperationalEvidence) Validate() error { if err := e.AcceptedChainPrefix.Validate(); err != nil { return fmt.Errorf("accepted_chain_prefix: %w", err) } - if len(e.MirrorReceipts) < 2 || len(e.MirrorReceipts) > 8 { - return errors.New("accepted head requires between 2 and 8 immutable mirror receipts") + if len(e.MirrorReceipts) < 1 || len(e.MirrorReceipts) > 8 { + return errors.New("accepted head requires between 1 and 8 immutable mirror receipts") } return validateSignedArtifactSet("mirror_receipts", e.MirrorReceipts) } @@ -109,8 +109,8 @@ func (p PhaseOperationalEvidence) Validate() error { return errors.New("accepted heads must be complete and ordered by one-based index") } } - if p.PublicWitnessQuorum < 2 { - return errors.New("public_witness_quorum must be at least 2") + if p.PublicWitnessQuorum < 1 { + return errors.New("public_witness_quorum must be at least 1") } if len(p.PublicWitnessReceipts) < int(p.PublicWitnessQuorum) || len(p.PublicWitnessReceipts) > 32 { return fmt.Errorf( @@ -154,7 +154,7 @@ func (b OperationalEvidenceBundle) Validate() error { if err := validateHashID("ceremony_id", b.CeremonyID); err != nil { return err } - minimumEnrollments := 6 // coordinator, release signer, two auditors, one participant, one witness + minimumEnrollments := 6 // coordinator, release signer, auditor, participant, witness, mirror if len(b.Enrollments) < minimumEnrollments || len(b.Enrollments) > 128 { return fmt.Errorf("enrollments must contain between %d and 128 records", minimumEnrollments) } diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 92bce23e..6ab7a8d6 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -43,6 +43,35 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Fatalf("complete operational bundle rejected: %v", err) } + t.Run("one witness and one mirror per head", func(t *testing.T) { + f := newOperationalBundleFixture(t) + for _, phase := range []*PhaseOperationalEvidence{&f.bundle.Phase1, &f.bundle.Phase2} { + phase.PublicWitnessQuorum = 1 + phase.PublicWitnessReceipts = phase.PublicWitnessReceipts[:1] + for i := range phase.AcceptedHeads { + phase.AcceptedHeads[i].MirrorReceipts = phase.AcceptedHeads[i].MirrorReceipts[:1] + } + } + resignBundle(t, &f) + if err := verify(f); err != nil { + t.Fatal(err) + } + f.bundle.Phase1.PublicWitnessQuorum = 2 + resignInvalidBundle(t, &f) + if err := verify(f); err == nil { + t.Fatal("higher agreed witness quorum was ignored") + } + }) + t.Run("zero witnesses", func(t *testing.T) { + f := newOperationalBundleFixture(t) + f.bundle.Phase1.PublicWitnessQuorum = 1 + f.bundle.Phase1.PublicWitnessReceipts = nil + resignInvalidBundle(t, &f) + if err := verify(f); err == nil { + t.Fatal("zero witnesses accepted") + } + }) + t.Run("missing enrollment", func(t *testing.T) { f := newOperationalBundleFixture(t) f.bundle.Enrollments = f.bundle.Enrollments[1:] @@ -101,10 +130,10 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Run("incomplete mirror evidence", func(t *testing.T) { f := newOperationalBundleFixture(t) f.bundle.Phase1.AcceptedHeads[0].MirrorReceipts = - f.bundle.Phase1.AcceptedHeads[0].MirrorReceipts[:1] + f.bundle.Phase1.AcceptedHeads[0].MirrorReceipts[:0] resignInvalidBundle(t, &f) if err := verify(f); err == nil { - t.Fatal("accepted head with one mirror unexpectedly accepted") + t.Fatal("accepted head with no mirrors unexpectedly accepted") } }) t.Run("swapped outbound custody direction", func(t *testing.T) { diff --git a/internal/mpcceremony/operational_prepare.go b/internal/mpcceremony/operational_prepare.go index 7d93ea54..5af36dc0 100644 --- a/internal/mpcceremony/operational_prepare.go +++ b/internal/mpcceremony/operational_prepare.go @@ -179,7 +179,7 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt } } for _, phase := range []Phase{Phase1, Phase2} { - p := PhaseOperationalEvidence{Phase: phase, PublicWitnessQuorum: 2, AcceptedHeads: []AcceptedHeadOperationalEvidence{}, RawBeaconResponses: []ArtifactRef{}} + p := PhaseOperationalEvidence{Phase: phase, PublicWitnessQuorum: 1, AcceptedHeads: []AcceptedHeadOperationalEvidence{}, RawBeaconResponses: []ArtifactRef{}} label := string(phase) closePair, closeAny := pick(label+" closure", func(v any) bool { c, ok := v.(*CloseRecord); return ok && c.Phase == phase }) p.Close = closePair @@ -196,8 +196,8 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt w, ok := v.(*PublicWitnessReceipt) return ok && w.Phase == phase && w.CloseID == close.CloseID }) - if len(p.PublicWitnessReceipts) < 2 { - result.Missing = append(result.Missing, label+": collect signed observations from at least two witnesses; expired windows cannot be recreated") + if len(p.PublicWitnessReceipts) < 1 { + result.Missing = append(result.Missing, label+": collect signed observations from at least one witness; expired windows cannot be recreated") } p.MultiRelayBeaconEvidence, closeAny = pick(label+" two-operator beacon evidence", func(v any) bool { b, ok := v.(*MultiRelayBeaconEvidence) @@ -260,8 +260,8 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt } break } - if len(h.MirrorReceipts) < 2 { - result.Missing = append(result.Missing, scope+": collect at least two signed mirror receipts for this exact head") + if len(h.MirrorReceipts) < 1 { + result.Missing = append(result.Missing, scope+": collect at least one signed mirror receipt for this exact head") } p.AcceptedHeads = append(p.AcceptedHeads, h) } diff --git a/internal/mpcceremony/single_auditor_test.go b/internal/mpcceremony/single_auditor_test.go new file mode 100644 index 00000000..65e1cf2d --- /dev/null +++ b/internal/mpcceremony/single_auditor_test.go @@ -0,0 +1,46 @@ +package mpcceremony + +import "testing" + +func TestSingleAuditorMinimumBothModes(t *testing.T) { + for _, mode := range []string{ModeRehearsal, ModeProduction} { + d := adversarialDefinition(t) + d.Mode = mode + d.Auditors = d.Auditors[:1] + if _, err := FinalizeCeremonyDefinition(d); err != nil { + t.Fatalf("%s one auditor: %v", mode, err) + } + p := InitParticipants{Coordinator: d.Coordinator, ReleaseSigner: d.ReleaseSigner, Auditors: d.Auditors, Roster: d.Roster} + if err := p.Validate(); err != nil { + t.Fatal(err) + } + d.Auditors = nil + if _, err := FinalizeCeremonyDefinition(d); err == nil { + t.Fatal("zero auditors accepted") + } + p.Auditors = nil + if err := p.Validate(); err == nil { + t.Fatal("zero auditors in init accepted") + } + } +} +func TestProductionDecisionOneAuditMinimum(t *testing.T) { + f := newProductionDecisionFixture(t, DecisionGO) + value := f.decision + value.DecisionID = "" + value.Audits = value.Audits[:1] + value.ExternalAudits = value.ExternalAudits[:1] + if _, err := NewProductionDecision(value); err != nil { + t.Fatal(err) + } + value.Audits = nil + if _, err := NewProductionDecision(value); err == nil { + t.Fatal("zero audits accepted") + } + value = f.decision + value.DecisionID = "" + value.ExternalAudits = nil + if _, err := NewProductionDecision(value); err == nil { + t.Fatal("zero external audits accepted") + } +} diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index ff2d4bdc..8d5385fa 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -73,8 +73,8 @@ func (p InitParticipants) Validate() error { if err := p.ReleaseSigner.Validate(); err != nil { return fmt.Errorf("release_signer: %w", err) } - if len(p.Auditors) < 2 { - return errors.New("at least two independent auditors are required") + if len(p.Auditors) < 1 { + return errors.New("at least one independent auditor is required") } if len(p.Auditors) > MaxAuditors { return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) From 3394553d6bd2df3f1884d2ccc7c90849ee0c1c87 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 11 Sep 2026 16:58:09 +0900 Subject: [PATCH 57/64] Fix single-auditor release CLI gate (#26) --- cmd/mpc-ceremony/cli_test.go | 16 ++++++++++------ cmd/mpc-ceremony/parse.go | 4 ++-- cmd/mpc-ceremony/usage.go | 1 - docs/single-observer-minimum.md | 2 ++ 4 files changed, 14 insertions(+), 9 deletions(-) diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 29174de7..c535cddf 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -646,12 +646,9 @@ func TestReleaseSignRequiresPairedIndependentAudits(t *testing.T) { want string }{ { - name: "one audit", - args: append(append([]string(nil), base...), - "--audit-report", "audit-1.json", - "--audit-signature", "audit-1.sig", - ), - want: "at least twice", + name: "zero audits", + args: append([]string(nil), base...), + want: "at least once", }, { name: "mismatched signatures", @@ -673,6 +670,13 @@ func TestReleaseSignRequiresPairedIndependentAudits(t *testing.T) { } }) } + oneAudit := append(append([]string(nil), base...), + "--audit-report", "audit-1.json", + "--audit-signature", "audit-1.sig", + ) + if _, err := parseInvocation(oneAudit); err != nil { + t.Fatalf("one audit rejected: %v", err) + } } func TestInitUsesContentAddressedIdentityInputs(t *testing.T) { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index a3e046cd..c371a609 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -1211,8 +1211,8 @@ func validateReplayOptions(replay ReplayOptions) error { } 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) < 1 { + return errors.New("--audit-report must be supplied at least once") } 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) diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 27c6b2df..5f662e79 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -417,7 +417,6 @@ Release authenticity is separate from MPC contribution identity. "release sign": `Usage: mpc-ceremony release sign --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --candidate-bundle DIR \ - --audit-report FILE --audit-signature FILE \ --audit-report FILE --audit-signature FILE \ --operational-evidence-root DIR \ --operational-bundle DIR/operational/evidence-bundle.json \ diff --git a/docs/single-observer-minimum.md b/docs/single-observer-minimum.md index a1500467..c6ce62bb 100644 --- a/docs/single-observer-minimum.md +++ b/docs/single-observer-minimum.md @@ -9,6 +9,8 @@ Release signing requires at least one verified passing transcript audit. Production decisions also require at least one distinct external audit signoff; this is a separate report requirement, not evidence supplied by a mirror or witness. Every supplied report is still validated, including additional reports. +The `release sign` command accepts one matching audit report/signature pair and +rejects zero, matching the verifier's threshold. Production still requires at least two participants per phase, every scheduled contribution, distinct signing identities and the existing independence checks. From e67374f665b7c91f6366a50eb6a0181c193fe643 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 11 Sep 2026 17:19:35 +0900 Subject: [PATCH 58/64] Allow short future beacons in rehearsals (#27) --- cmd/mpc-ceremony/integration_test.go | 1 + cmd/mpc-ceremony/parse.go | 8 ++++++ cmd/mpc-ceremony/rehearsal.go | 2 +- cmd/mpc-ceremony/rehearsal_test.go | 19 ++++++++++++- cmd/mpc-ceremony/types.go | 1 + cmd/mpc-ceremony/usage.go | 7 +++-- internal/mpcrehearsal/config.go | 10 ++++--- internal/mpcrehearsal/config_test.go | 40 ++++++++++++++++++++++++++++ 8 files changed, 81 insertions(+), 7 deletions(-) create mode 100644 internal/mpcrehearsal/config_test.go diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index abc21e64..cdcaf398 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -108,6 +108,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--auditor-id", "--auditor-signing-key", "--beacon", + "--beacon-lead-seconds", "--beacon-signature", "--beacon-round", "--beacon-round-lead", diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index c371a609..6c194495 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -12,6 +12,7 @@ import ( "strings" "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" ) const supportedKeyVersion = "ownership-destination-v2" @@ -172,6 +173,7 @@ func parseRehearsalInit(args []string) (RehearsalInitOptions, error) { 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") + fs.Uint64Var(&options.BeaconLeadSeconds, "beacon-lead-seconds", rehearsalBeaconLeadSeconds, "non-production witness window for this rehearsal") fs.Var(&allowedBinaries, "allowed-binary", "additional exact mpc-ceremony binary to sign into the platform allowlist (repeatable)") if err := parseFlags(fs, args); err != nil { return options, err @@ -182,6 +184,12 @@ func parseRehearsalInit(args []string) (RehearsalInitOptions, error) { return options, err } } + if options.BeaconLeadSeconds < uint64(mpcrehearsal.MinimumBeaconLeadSeconds) { + return options, fmt.Errorf("--beacon-lead-seconds must be at least %d", mpcrehearsal.MinimumBeaconLeadSeconds) + } + if options.BeaconLeadSeconds > uint64(^uint32(0)) { + return options, errors.New("--beacon-lead-seconds is too large") + } return options, requireValues( value("--created-at", options.CreatedAt), pathValue("--out-dir", options.OutDir), diff --git a/cmd/mpc-ceremony/rehearsal.go b/cmd/mpc-ceremony/rehearsal.go index bb03c31b..76f1d409 100644 --- a/cmd/mpc-ceremony/rehearsal.go +++ b/cmd/mpc-ceremony/rehearsal.go @@ -21,7 +21,7 @@ func executeRehearsalInit(options RehearsalInitOptions) (result CommandResult, e if err := mpcrehearsal.Generate( options.OutDir, rehearsalParticipantCount, - rehearsalBeaconLeadSeconds, + uint32(options.BeaconLeadSeconds), ); err != nil { return CommandResult{}, err } diff --git a/cmd/mpc-ceremony/rehearsal_test.go b/cmd/mpc-ceremony/rehearsal_test.go index 163343ff..fe19d659 100644 --- a/cmd/mpc-ceremony/rehearsal_test.go +++ b/cmd/mpc-ceremony/rehearsal_test.go @@ -23,13 +23,30 @@ func TestParseRehearsalInitIsNarrowAndExplicit(t *testing.T) { t.Fatalf("command = %q", invocation.Command) } options := invocation.Options.(RehearsalInitOptions) - if options.CreatedAt != "2026-08-20T06:00:00Z" || options.OutDir != "/secure/rehearsal" { + if options.CreatedAt != "2026-08-20T06:00:00Z" || options.OutDir != "/secure/rehearsal" || options.BeaconLeadSeconds != rehearsalBeaconLeadSeconds { t.Fatalf("options = %+v", options) } + custom, err := parseInvocation([]string{ + "rehearsal", "init", + "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", + "--beacon-lead-seconds", "12", + }) + if err != nil { + t.Fatal(err) + } + if got := custom.Options.(RehearsalInitOptions).BeaconLeadSeconds; got != 12 { + t.Fatalf("custom beacon lead = %d, want 12", got) + } + 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"}, + "short beacon lead": { + "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", "--beacon-lead-seconds", "11", + }, "production mode": { "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", "--out-dir", "/secure/rehearsal", "--mode", "production", diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 4c0d6b26..b2be5fba 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -90,6 +90,7 @@ type InitOptions struct { type RehearsalInitOptions struct { CreatedAt string OutDir string + BeaconLeadSeconds uint64 AllowedBinaryPaths []string } diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 5f662e79..3a324363 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -138,18 +138,21 @@ 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 \ - [--allowed-binary FILE ...] + [--beacon-lead-seconds N] [--allowed-binary FILE ...] 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 \ - [--allowed-binary FILE ...] + [--beacon-lead-seconds N] [--allowed-binary FILE ...] 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. +The witness window defaults to 300 seconds. --beacon-lead-seconds may shorten +it to at least 12 seconds for automated tests; the chosen non-production value +is signed into the rehearsal definition and cannot change production policy. `, "inspect": inspectHelp + ` Authenticated record projections are also available as subcommands: diff --git a/internal/mpcrehearsal/config.go b/internal/mpcrehearsal/config.go index 6566879c..2549696d 100644 --- a/internal/mpcrehearsal/config.go +++ b/internal/mpcrehearsal/config.go @@ -20,7 +20,11 @@ import ( const ( minRehearsalParticipants = 3 maxRehearsalParticipants = 20 - minRehearsalBeaconLead = 60 + // MinimumBeaconLeadSeconds gives automated rehearsals four Quicknet + // periods to commit to a round that does not exist yet. Rehearsal outputs + // are explicitly non-production; production policy has its own 24-hour + // minimum in mpcceremony. + MinimumBeaconLeadSeconds = 12 ) type generatedIdentity struct { @@ -37,10 +41,10 @@ func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (er maxRehearsalParticipants, ) } - if beaconWitnessLead < minRehearsalBeaconLead { + if beaconWitnessLead < MinimumBeaconLeadSeconds { return fmt.Errorf( "beacon witness lead must be at least %d seconds", - minRehearsalBeaconLead, + MinimumBeaconLeadSeconds, ) } if err := os.Mkdir(outDir, 0o700); err != nil { diff --git a/internal/mpcrehearsal/config_test.go b/internal/mpcrehearsal/config_test.go new file mode 100644 index 00000000..0254deda --- /dev/null +++ b/internal/mpcrehearsal/config_test.go @@ -0,0 +1,40 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcrehearsal + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" +) + +func TestGenerateUsesShortAutomatedBeaconLead(t *testing.T) { + root := filepath.Join(t.TempDir(), "rehearsal") + if err := Generate(root, minRehearsalParticipants, MinimumBeaconLeadSeconds); err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(filepath.Join(root, "config", "policy.json")) + if err != nil { + t.Fatal(err) + } + var policy struct { + Beacon struct { + Lead uint32 `json:"minimum_witness_lead_seconds"` + } `json:"beacon_policy"` + } + if err := json.Unmarshal(raw, &policy); err != nil { + t.Fatal(err) + } + if policy.Beacon.Lead != MinimumBeaconLeadSeconds { + t.Fatalf("beacon lead = %d, want %d", policy.Beacon.Lead, MinimumBeaconLeadSeconds) + } +} + +func TestGenerateRejectsShorterBeaconLead(t *testing.T) { + err := Generate(filepath.Join(t.TempDir(), "rehearsal"), minRehearsalParticipants, MinimumBeaconLeadSeconds-1) + if err == nil { + t.Fatal("short beacon lead was accepted") + } +} From df965bda301606466e338b3fdd122ccab85d4ffa Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Fri, 11 Sep 2026 18:23:50 +0900 Subject: [PATCH 59/64] Align rehearsal helper with shared beacon minimum (#28) --- scripts/mpc-rehearsal-config/main.go | 2 +- scripts/mpc-rehearsal-config/main_test.go | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/scripts/mpc-rehearsal-config/main.go b/scripts/mpc-rehearsal-config/main.go index f4651d89..9c299f53 100644 --- a/scripts/mpc-rehearsal-config/main.go +++ b/scripts/mpc-rehearsal-config/main.go @@ -18,7 +18,7 @@ func main() { beaconWitnessLead := flag.Uint( "beacon-witness-lead-seconds", 300, - "signed rehearsal witness/round lead in seconds (minimum 60)", + fmt.Sprintf("signed rehearsal witness/round lead in seconds (minimum %d)", mpcrehearsal.MinimumBeaconLeadSeconds), ) flag.Parse() if *outDir == "" || flag.NArg() != 0 { diff --git a/scripts/mpc-rehearsal-config/main_test.go b/scripts/mpc-rehearsal-config/main_test.go index c7db51d2..44601cbf 100644 --- a/scripts/mpc-rehearsal-config/main_test.go +++ b/scripts/mpc-rehearsal-config/main_test.go @@ -7,6 +7,7 @@ import ( "proof-tool/internal/keybundle" "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" ) func TestGenerateCreatesValidatedCanonicalRehearsalInputs(t *testing.T) { @@ -74,7 +75,7 @@ func TestGenerateRejectsUnsafeParticipantCounts(t *testing.T) { } func TestGenerateRejectsShortBeaconWitnessLead(t *testing.T) { - if err := generate(filepath.Join(t.TempDir(), "rehearsal"), 3, 59); err == nil { - t.Fatal("accepted a rehearsal beacon witness lead below 60 seconds") + if err := generate(filepath.Join(t.TempDir(), "rehearsal"), 3, mpcrehearsal.MinimumBeaconLeadSeconds-1); err == nil { + t.Fatalf("accepted a rehearsal beacon witness lead below %d seconds", mpcrehearsal.MinimumBeaconLeadSeconds) } } From 8500f65578dd33a3e897fea33813e75bd3bb2faf Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Sat, 12 Sep 2026 10:22:29 +0900 Subject: [PATCH 60/64] fix: make ceremony output recovery atomic --- cmd/mpc-ceremony/identity.go | 150 ++++++++++++++++++++++++----- cmd/mpc-ceremony/identity_test.go | 93 ++++++++++++++++++ cmd/mpc-ceremony/rehearsal_test.go | 54 +++++++++++ cmd/mpc-ceremony/usage.go | 15 ++- internal/mpcceremony/finalize.go | 30 +++--- internal/mpcceremony/workflow.go | 55 ++++++++--- 6 files changed, 340 insertions(+), 57 deletions(-) diff --git a/cmd/mpc-ceremony/identity.go b/cmd/mpc-ceremony/identity.go index 1b200de3..d8e560f5 100644 --- a/cmd/mpc-ceremony/identity.go +++ b/cmd/mpc-ceremony/identity.go @@ -4,20 +4,33 @@ package main import ( + "bytes" "crypto/ed25519" "crypto/rand" "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "os" "path/filepath" + "proof-tool/internal/keybundle" "proof-tool/internal/mpcceremony" ) const generatedIdentityKeyIDPrefix = "ed25519:" +const identityRecoverySchema = "proof-tool-identity-recovery-v1" + +type identityRecoveryIntent struct { + Schema string `json:"schema"` + IdentityID string `json:"identity_id"` + DisplayName string `json:"display_name"` + PrivateKeyOut string `json:"private_key_out"` + PublicIdentityOut string `json:"public_identity_out"` +} + func executeIdentityGenerate(options IdentityGenerateOptions) (CommandResult, error) { privateTarget, err := resolvedFreshTarget(options.PrivateKeyOut) if err != nil { @@ -30,16 +43,33 @@ func executeIdentityGenerate(options IdentityGenerateOptions) (CommandResult, er if privateTarget == publicTarget { return CommandResult{}, errors.New("private and public output paths must be distinct") } - if err := requireFreshTarget(options.PrivateKeyOut); err != nil { + intentPath := privateTarget + ".identity-recovery.json" + if intentPath == publicTarget { + return CommandResult{}, errors.New("public identity output conflicts with the private-key recovery record") + } + privateExists, err := targetExists(options.PrivateKeyOut) + if err != nil { return CommandResult{}, fmt.Errorf("private key output: %w", err) } - if err := requireFreshTarget(options.PublicIdentityOut); err != nil { + publicExists, err := targetExists(options.PublicIdentityOut) + if 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) + if publicExists && !privateExists { + return CommandResult{}, errors.New("public identity output already exists without its private key") + } + var publicKey ed25519.PublicKey + var privateKey ed25519.PrivateKey + if privateExists { + privateKey, publicKey, err = keybundle.LoadExistingPrivateKey(options.PrivateKeyOut) + if err != nil { + return CommandResult{}, fmt.Errorf("continue identity from retained private key: %w", err) + } + } else { + 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) @@ -57,6 +87,20 @@ func executeIdentityGenerate(options IdentityGenerateOptions) (CommandResult, er if err != nil { return CommandResult{}, fmt.Errorf("encode public ceremony identity: %w", err) } + intent := identityRecoveryIntent{ + Schema: identityRecoverySchema, IdentityID: options.IdentityID, + DisplayName: options.DisplayName, PrivateKeyOut: privateTarget, + PublicIdentityOut: publicTarget, + } + if err := ensureIdentityRecoveryIntent(intentPath, intent, privateExists); err != nil { + return CommandResult{}, err + } + if publicExists { + if err := verifyExistingPublicIdentity(options.PublicIdentityOut, publicIdentity); err != nil { + return CommandResult{}, err + } + return identityGenerateResult(identity, options), nil + } seed := privateKey.Seed() defer zeroBytes(seed) @@ -65,25 +109,28 @@ func executeIdentityGenerate(options IdentityGenerateOptions) (CommandResult, er 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. + // Persist the secret first. If public-file creation is interrupted, the + // exact public identity can be derived from this retained key on the next + // invocation; a second private key is never generated automatically. + if !privateExists { + if err := writeFreshOperationalFile(options.PrivateKeyOut, privateSeedHex, 0o600); err != nil { + return CommandResult{}, fmt.Errorf("write private key: %w", err) + } + if err := syncDirectory(filepath.Dir(options.PrivateKeyOut)); err != nil { + return CommandResult{}, fmt.Errorf("sync private key directory: %w", err) + } + } if err := writeFreshOperationalFile(options.PublicIdentityOut, publicIdentity, 0o644); err != nil { - return CommandResult{}, err + return CommandResult{}, fmt.Errorf("write public identity (private key retained for exact continuation): %w", 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 identityGenerateResult(identity, options), nil +} + +func identityGenerateResult(identity mpcceremony.Identity, options IdentityGenerateOptions) CommandResult { return CommandResult{ Identity: &identity, Outputs: map[string]string{ @@ -91,7 +138,62 @@ func executeIdentityGenerate(options IdentityGenerateOptions) (CommandResult, er "public_identity": options.PublicIdentityOut, }, Summary: "generated Ed25519 ceremony identity; keep private_key_SECRET local and share only public_identity", - }, nil + } +} + +func verifyExistingPublicIdentity(path string, expected []byte) error { + info, err := os.Lstat(path) + if err != nil { + return fmt.Errorf("inspect existing public identity: %w", err) + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Size() > 1<<20 { + return errors.New("existing public identity is not a safe regular file") + } + actual, err := os.ReadFile(path) + if err != nil { + return fmt.Errorf("read existing public identity: %w", err) + } + if !bytes.Equal(actual, expected) { + return errors.New("existing public identity does not match the retained private key and recovery record") + } + return nil +} + +func ensureIdentityRecoveryIntent(path string, expected identityRecoveryIntent, privateExists bool) error { + info, statErr := os.Lstat(path) + if statErr == nil { + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 || info.Mode().Perm()&0o077 != 0 || info.Size() > 1<<20 { + return errors.New("identity recovery record must be a protected regular file") + } + } else if !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("inspect identity recovery record: %w", statErr) + } + raw, err := os.ReadFile(path) + if errors.Is(err, os.ErrNotExist) { + if privateExists { + return errors.New("retained private key has no identity recovery record; refusing to guess its original public identity") + } + encoded, marshalErr := json.Marshal(expected) + if marshalErr != nil { + return marshalErr + } + encoded = append(encoded, '\n') + if writeErr := writeFreshOperationalFile(path, encoded, 0o600); writeErr != nil { + return fmt.Errorf("write identity recovery record: %w", writeErr) + } + if syncErr := syncDirectory(filepath.Dir(path)); syncErr != nil { + return fmt.Errorf("sync identity recovery directory: %w", syncErr) + } + return nil + } + if err != nil { + return fmt.Errorf("read identity recovery record: %w", err) + } + var actual identityRecoveryIntent + if json.Unmarshal(raw, &actual) != nil || actual != expected { + return errors.New("identity recovery record does not match the requested identity and output paths") + } + return nil } func resolvedFreshTarget(path string) (string, error) { @@ -113,15 +215,15 @@ func resolvedFreshTarget(path string) (string, error) { return filepath.Join(parent, filepath.Base(absolute)), nil } -func requireFreshTarget(path string) error { +func targetExists(path string) (bool, error) { _, err := os.Lstat(path) switch { case err == nil: - return errors.New("output already exists") + return true, nil case errors.Is(err, os.ErrNotExist): - return nil + return false, nil default: - return err + return false, err } } diff --git a/cmd/mpc-ceremony/identity_test.go b/cmd/mpc-ceremony/identity_test.go index 8f74f2a2..45cec5c4 100644 --- a/cmd/mpc-ceremony/identity_test.go +++ b/cmd/mpc-ceremony/identity_test.go @@ -166,6 +166,99 @@ func TestIdentityGenerateDoesNotOverwriteOrCreatePartialSecretOutput(t *testing. } } +func TestIdentityGenerateContinuesFromRetainedPrivateKey(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "identity.private.hex") + publicPath := filepath.Join(root, "identity.json") + options := IdentityGenerateOptions{ + IdentityID: "participant-03", + DisplayName: "Participant Three", + PrivateKeyOut: privatePath, + PublicIdentityOut: publicPath, + } + first, err := executeIdentityGenerate(options) + if err != nil { + t.Fatal(err) + } + privateBefore, err := os.ReadFile(privatePath) + if err != nil { + t.Fatal(err) + } + publicBefore, err := os.ReadFile(publicPath) + if err != nil { + t.Fatal(err) + } + exact, err := executeIdentityGenerate(options) + if err != nil { + t.Fatal("exact completed identity retry was not adopted:", err) + } + if exact.Identity == nil || first.Identity == nil || *exact.Identity != *first.Identity { + t.Fatal("exact completed identity retry changed metadata") + } + if err := os.Remove(publicPath); err != nil { + t.Fatal(err) + } + second, err := executeIdentityGenerate(options) + if err != nil { + t.Fatal(err) + } + privateAfter, err := os.ReadFile(privatePath) + if err != nil { + t.Fatal(err) + } + publicAfter, err := os.ReadFile(publicPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(privateBefore, privateAfter) || !bytes.Equal(publicBefore, publicAfter) { + t.Fatal("continuation replaced the keypair instead of deriving the same public identity") + } + if first.Identity == nil || second.Identity == nil || *first.Identity != *second.Identity { + t.Fatal("continued identity metadata changed") + } + changed := options + changed.DisplayName = "Different Person" + if err := os.Remove(publicPath); err != nil { + t.Fatal(err) + } + if _, err := executeIdentityGenerate(changed); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("changed recovery inputs error = %v", err) + } +} + +func TestIdentityGenerateRejectsChangedCompletedPublicIdentity(t *testing.T) { + root := t.TempDir() + options := IdentityGenerateOptions{ + IdentityID: "participant-03", DisplayName: "Participant Three", + PrivateKeyOut: filepath.Join(root, "identity.private.hex"), + PublicIdentityOut: filepath.Join(root, "identity.json"), + } + if _, err := executeIdentityGenerate(options); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(options.PublicIdentityOut, []byte("changed\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := executeIdentityGenerate(options); err == nil || !strings.Contains(err.Error(), "does not match") { + t.Fatalf("changed completed public identity error = %v", err) + } +} + +func TestIdentityGenerateDoesNotGuessLegacyPrivateKeyInputs(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "identity.private.hex") + if err := os.WriteFile(privatePath, []byte(strings.Repeat("01", 32)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + _, err := executeIdentityGenerate(IdentityGenerateOptions{ + IdentityID: "participant-03", DisplayName: "Participant Three", + PrivateKeyOut: privatePath, PublicIdentityOut: filepath.Join(root, "identity.json"), + }) + if err == nil || !strings.Contains(err.Error(), "no identity recovery record") { + t.Fatalf("legacy private key error = %v", err) + } +} + func TestIdentityGenerateRejectsSameResolvedOutput(t *testing.T) { root := t.TempDir() realDir := filepath.Join(root, "real") diff --git a/cmd/mpc-ceremony/rehearsal_test.go b/cmd/mpc-ceremony/rehearsal_test.go index fe19d659..1bf444df 100644 --- a/cmd/mpc-ceremony/rehearsal_test.go +++ b/cmd/mpc-ceremony/rehearsal_test.go @@ -4,8 +4,12 @@ package main import ( + "path/filepath" "strings" "testing" + + "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" ) func TestParseRehearsalInitIsNarrowAndExplicit(t *testing.T) { @@ -65,6 +69,56 @@ func TestParseRehearsalInitIsNarrowAndExplicit(t *testing.T) { } } +func TestInitExactRetryAcceptsAtomicallyPublishedTree(t *testing.T) { + base, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + root := filepath.Join(base, "fixture") + if err := mpcrehearsal.Generate(root, rehearsalParticipantCount, rehearsalBeaconLeadSeconds); err != nil { + t.Fatal(err) + } + participantsPath := filepath.Join(root, "config", "participants.json") + participants, err := mpcceremony.LoadInitParticipants(participantsPath) + if err != nil { + t.Fatal(err) + } + policy, err := mpcceremony.LoadInitPolicy(filepath.Join(root, "config", "policy.json")) + if err != nil { + t.Fatal(err) + } + circuit, err := mpcceremony.CompileForKeyVersion(rehearsalKeyVersion) + if err != nil { + t.Fatal(err) + } + options := mpcceremony.InitFilesOptions{ + RootDir: filepath.Join(root, "public"), Circuit: circuit, + CoordinatorPrivateKeyPath: filepath.Join(root, "keys", "coordinator.ed25519.private.hex"), + Definition: mpcceremony.DefinitionOptions{ + Mode: mpcceremony.ModeRehearsal, CreatedAt: "2026-09-12T00:00:00Z", SessionNonceHex: strings.Repeat("5a", 32), + Software: mpcceremony.SoftwareBinding{ + ProofToolVersion: "test", GnarkVersion: mpcceremony.GnarkVersion, GnarkCryptoVersion: mpcceremony.GnarkCryptoVersion, + DrandVersion: mpcceremony.DrandVersion, GoVersion: "go-test", GoOS: "linux", GoArch: "amd64", GoAMD64: "v1", + Compiler: "gc", BuildMode: "exe", TrimPath: true, SourceCommit: strings.Repeat("6b", 20), + ToolBinary: mpcceremony.NewDigest([]byte("test mpc-ceremony binary")), + }, + Coordinator: participants.Coordinator, ReleaseSigner: participants.ReleaseSigner, Auditors: participants.Auditors, Roster: participants.Roster, + Phase1Policy: policy.Phase1Policy, Phase2Policy: policy.Phase2Policy, BeaconPolicy: policy.BeaconPolicy, + }, + } + first, err := mpcceremony.InitializeCeremonyFiles(options) + if err != nil { + t.Fatal(err) + } + second, err := mpcceremony.InitializeCeremonyFiles(options) + if err != nil { + t.Fatal("exact initialization retry rejected the committed tree:", err) + } + if first.Definition.CeremonyID != second.Definition.CeremonyID { + t.Fatal("exact initialization retry changed the ceremony") + } +} + func TestRehearsalInitHelpLabelsOutputAsNonProduction(t *testing.T) { t.Parallel() diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 3a324363..17e920f0 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -68,8 +68,9 @@ Commands: ops import-signature Import and verify a raw offline Ed25519 signature ops verify Verify a signed operational record fail-closed -All input and output paths are explicit. Outputs must not already exist. There -are no network, automatic-discovery, overwrite, deterministic-randomness, or +All input and output paths are explicit. Outputs must not already exist unless +command-specific help documents byte-exact crash continuation. There are no +network, automatic-discovery, overwrite, deterministic-randomness, or verification-bypass flags. Run "mpc-ceremony help " for command-specific help. @@ -133,8 +134,14 @@ 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. +Both parent directories must already exist and the output paths must be +distinct. Before creating the key, the command saves a protected recovery +record beside the private output. If creation stops after the private key is +saved but before the public file appears, repeating the exact command derives +that public file from the same key. If both files were completed before the +caller saw success, repeating the exact command verifies and adopts the exact +pair. Changed identity inputs or output bytes are rejected and a second key is +never generated automatically. Private key bytes are never printed. `, "rehearsal": `Usage: mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR \ diff --git a/internal/mpcceremony/finalize.go b/internal/mpcceremony/finalize.go index 6d7b8f6a..7d9f9017 100644 --- a/internal/mpcceremony/finalize.go +++ b/internal/mpcceremony/finalize.go @@ -1836,23 +1836,19 @@ func writeR1CSNoReplace(path string, circuit *CompiledCircuit) (ArtifactRef, err } func saveNativeNoReplace(path string, save func(string) error) (err error) { - dir := filepath.Dir(path) - temp, err := os.CreateTemp(dir, "."+filepath.Base(path)+".partial-*") - if err != nil { - return err - } - tempPath := temp.Name() - if err := temp.Close(); err != nil { - return err - } - if err := os.Remove(tempPath); err != nil { + // Finalize writes into a fresh, private staging directory and publishes the + // entire directory atomically only after every artifact has been verified. + // A second per-file hard-link publication is unnecessary here and is not + // portable across all Docker Desktop bind-mount implementations. + if _, err := os.Lstat(path); err == nil { + return fmt.Errorf("native artifact destination already exists: %w", fs.ErrExist) + } else if !errors.Is(err, fs.ErrNotExist) { return err } - defer os.Remove(tempPath) - if err := save(tempPath); err != nil { + if err := save(path); err != nil { return err } - file, err := os.Open(tempPath) + file, err := os.Open(path) if err != nil { return err } @@ -1863,8 +1859,12 @@ func saveNativeNoReplace(path string, save func(string) error) (err error) { if err := file.Close(); err != nil { return err } - if err := publishFileNoReplace(tempPath, path); err != nil { - return fmt.Errorf("publish native artifact without replacement: %w", err) + info, err := os.Lstat(path) + if err != nil { + return err + } + if !info.Mode().IsRegular() || info.Mode()&os.ModeSymlink != 0 { + return errors.New("native artifact output is not a real regular file") } return nil } diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 8d5385fa..9c0b4bbf 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -273,15 +273,49 @@ type InitFilesResult struct { Phase1ChainSignaturePath string } -// InitializeCeremonyFiles creates a fresh ceremony root and deterministic -// Phase 1 genesis. The root must not already exist. +// InitializeCeremonyFiles builds a complete ceremony beside its destination and +// publishes the directory with one rename. A crash can leave a hidden staging +// directory, but never a partially initialized ceremony at RootDir. func InitializeCeremonyFiles(options InitFilesOptions) (result InitFilesResult, err error) { - if options.Circuit == nil || options.Circuit.R1CS == nil { - return result, errors.New("compiled circuit is required") - } if strings.TrimSpace(options.RootDir) == "" { return result, errors.New("fresh ceremony root is required") } + staging, err := createRecoveryStagingDir(options.RootDir) + if err != nil { + return result, fmt.Errorf("create ceremony staging directory: %w", err) + } + defer func() { + if staging != "" && !publicationWasCommitted(err) { + _ = os.RemoveAll(staging) + } + }() + stagedOptions := options + stagedOptions.RootDir = staging + result, err = initializeCeremonyFilesInRoot(stagedOptions) + if err != nil { + return result, err + } + if err = publishDirectoryNoReplaceOrExact(staging, options.RootDir); err != nil { + return InitFilesResult{}, fmt.Errorf("publish initialized ceremony: %w", err) + } + staging = "" + result.DefinitionPath = filepath.Join(options.RootDir, "ceremony.json") + result.DefinitionSignaturePath = filepath.Join(options.RootDir, "ceremony.sig") + result.CoordinatorPublicKeyPath = filepath.Join(options.RootDir, "coordinator-public-key.hex") + result.R1CSPath, err = resolveArtifactPath(options.RootDir, options.Circuit.Binding.R1CS.Name) + if err != nil { + return InitFilesResult{}, err + } + result.Phase1GenesisPath = filepath.Join(options.RootDir, "phase1", "genesis.bin") + result.Phase1ChainPath = filepath.Join(options.RootDir, "phase1", "chain-0000.json") + result.Phase1ChainSignaturePath = filepath.Join(options.RootDir, "phase1", "chain-0000.sig") + return result, nil +} + +func initializeCeremonyFilesInRoot(options InitFilesOptions) (result InitFilesResult, err error) { + if options.Circuit == nil || options.Circuit.R1CS == nil { + return result, errors.New("compiled circuit is required") + } if err := options.Circuit.Binding.Validate(); err != nil { return result, fmt.Errorf("compiled circuit binding: %w", err) } @@ -292,15 +326,9 @@ func InitializeCeremonyFiles(options InitFilesOptions) (result InitFilesResult, if err != nil { return result, fmt.Errorf("coordinator signing key: %w", err) } - if err := os.Mkdir(options.RootDir, 0o700); err != nil { - return result, fmt.Errorf("create fresh ceremony root: %w", err) + if info, err := os.Lstat(options.RootDir); err != nil || !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return result, errors.New("private ceremony staging root is required") } - createdRoot := true - defer func() { - if err != nil && createdRoot && !publicationWasCommitted(err) { - _ = os.RemoveAll(options.RootDir) - } - }() phase1Dir := filepath.Join(options.RootDir, "phase1") if err := os.Mkdir(phase1Dir, 0o700); err != nil { return result, fmt.Errorf("create Phase 1 directory: %w", err) @@ -379,7 +407,6 @@ func InitializeCeremonyFiles(options InitFilesOptions) (result InitFilesResult, } result.Definition = definition result.Phase1Chain = chain - createdRoot = false return result, nil } From 7ba406f0a6066f10b668ae8c553ab45e897f9fe4 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Sat, 12 Sep 2026 20:08:10 +0900 Subject: [PATCH 61/64] fix: create custody export parents safely (#30) --- cmd/mpc-ceremony/ops.go | 6 ++++++ cmd/mpc-ceremony/public_witness_ops_test.go | 15 +++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index c8ec8ad7..0c9084dd 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -216,6 +216,12 @@ func executeOpsExportSigning(options OpsExportSigningOptions) (result CommandRes } func writeOperationalSigningExport(outDir string, canonical, request []byte) (canonicalPath, requestPath string, err error) { + // The leaf must remain fresh, but guided custody outputs may live below a + // first-use parent such as /work/custody. Creating only that parent cannot + // overwrite a prior signing export. + if err := os.MkdirAll(filepath.Dir(outDir), 0o700); err != nil { + return "", "", fmt.Errorf("create signing export parent: %w", err) + } if err := os.Mkdir(outDir, 0o700); err != nil { return "", "", fmt.Errorf("create fresh signing export directory: %w", err) } diff --git a/cmd/mpc-ceremony/public_witness_ops_test.go b/cmd/mpc-ceremony/public_witness_ops_test.go index 42774139..0d0a31da 100644 --- a/cmd/mpc-ceremony/public_witness_ops_test.go +++ b/cmd/mpc-ceremony/public_witness_ops_test.go @@ -263,6 +263,21 @@ func TestWriteOperationalSigningExportCleansPartialPublication(t *testing.T) { } } +func TestWriteOperationalSigningExportCreatesMissingParentButNotExistingLeaf(t *testing.T) { + root := t.TempDir() + outDir := filepath.Join(root, "custody", "outbound") + canonical, request, err := writeOperationalSigningExport(outDir, []byte("canonical"), []byte("request")) + if err != nil { + t.Fatal(err) + } + if canonical != filepath.Join(outDir, "canonical.json") || request != filepath.Join(outDir, "signing-request.json") { + t.Fatalf("unexpected export paths: %q %q", canonical, request) + } + if _, _, err := writeOperationalSigningExport(outDir, []byte("canonical"), []byte("request")); err == nil { + t.Fatal("existing signing export leaf was overwritten") + } +} + func trustOptionsFromArgs(t *testing.T, args []string) InspectDefinitionOptions { t.Helper() invocation, err := parseInvocation(append([]string{"inspect", "definition"}, args...)) From 47bec5663d04a4f8ac330fc38f126e6e7c1140f1 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:14:52 +0900 Subject: [PATCH 62/64] Add storage-first checkpoints and optional assurance policy (#31) * feat: add signed storage-first checkpoints * feat(mpc): make assurance controls policy-driven * test(mpc): cover zero-assurance release lifecycle * style(mpc): satisfy ceremony lint * test(mpc): exercise checkpoint with released binary identity * test(mpc): bind checkpoint fixture to command binary * feat(mpc): expose signed beacon lead to guides * test(mpc): derive fixture identity from command binary * feat: authenticate phase one closure checkpoints * test: permit one-turn checkpoint closure fixture * test: separate progress from command results * fix: replay stored closure checkpoints * feat: authenticate phase one beacon checkpoints * fix: harden guarded checkpoint transitions * feat: authenticate sealed phase one checkpoints * feat: authenticate phase two initialization checkpoints * feat: authenticate phase two participant turns * feat: authenticate phase two closure checkpoints * feat: authenticate finalized candidate checkpoints * feat: authenticate signed final release checkpoints * fix: expose complete checkpoint lifecycle state --- cmd/mpc-ceremony/checkpoint_command.go | 1914 +++++++++++++++++ cmd/mpc-ceremony/checkpoint_command_test.go | 1339 ++++++++++++ cmd/mpc-ceremony/checkpoint_inspect_test.go | 283 +++ cmd/mpc-ceremony/checkpoint_open_other.go | 44 + cmd/mpc-ceremony/checkpoint_open_unix.go | 74 + cmd/mpc-ceremony/cli_test.go | 85 +- cmd/mpc-ceremony/decision_test.go | 14 +- cmd/mpc-ceremony/executor.go | 34 + cmd/mpc-ceremony/inspect.go | 275 ++- cmd/mpc-ceremony/inspect_test.go | 3 + cmd/mpc-ceremony/integration_test.go | 13 + cmd/mpc-ceremony/journey_inspection.go | 12 +- cmd/mpc-ceremony/journey_inspection_test.go | 23 +- cmd/mpc-ceremony/main.go | 7 +- cmd/mpc-ceremony/ops_bundle.go | 14 - cmd/mpc-ceremony/ops_bundle_test.go | 3 + cmd/mpc-ceremony/parse.go | 117 +- cmd/mpc-ceremony/rehearsal.go | 3 +- cmd/mpc-ceremony/rehearsal_test.go | 13 + cmd/mpc-ceremony/types.go | 310 ++- cmd/mpc-ceremony/usage.go | 149 +- docs/mpc-ceremony-release.md | 56 +- docs/single-observer-minimum.md | 49 +- internal/mpcceremony/adversarial_test.go | 12 +- internal/mpcceremony/audit.go | 207 +- internal/mpcceremony/chain.go | 54 +- internal/mpcceremony/chain_test.go | 3 + internal/mpcceremony/checkpoint.go | 1139 ++++++++++ internal/mpcceremony/checkpoint_test.go | 990 +++++++++ internal/mpcceremony/close_timing_test.go | 37 + internal/mpcceremony/deceptive_names_test.go | 23 + internal/mpcceremony/decision.go | 177 +- internal/mpcceremony/decision_test.go | 12 +- internal/mpcceremony/definition.go | 155 +- internal/mpcceremony/definition_test.go | 167 ++ .../direct_acceptance_boundary_test.go | 80 +- internal/mpcceremony/finalize.go | 22 + .../mpcceremony/lifecycle_adversarial_test.go | 61 + internal/mpcceremony/model.go | 9 +- internal/mpcceremony/operational.go | 18 +- internal/mpcceremony/operational_builder.go | 3 + internal/mpcceremony/operational_bundle.go | 91 +- .../mpcceremony/operational_bundle_test.go | 97 +- internal/mpcceremony/operational_prepare.go | 25 +- internal/mpcceremony/single_auditor_test.go | 135 +- internal/mpcceremony/software.go | 72 +- internal/mpcceremony/submission.go | 375 ++++ internal/mpcceremony/submission_test.go | 186 ++ .../testdata/workflowhelper/main.go | 560 ++++- internal/mpcceremony/workflow.go | 295 ++- internal/mpcrehearsal/config.go | 75 +- internal/mpcrehearsal/config_test.go | 28 + scripts/mpc-rehearsal-config/main.go | 3 +- .../main.go | 96 +- .../main_test.go | 13 + 55 files changed, 9530 insertions(+), 524 deletions(-) create mode 100644 cmd/mpc-ceremony/checkpoint_command.go create mode 100644 cmd/mpc-ceremony/checkpoint_command_test.go create mode 100644 cmd/mpc-ceremony/checkpoint_inspect_test.go create mode 100644 cmd/mpc-ceremony/checkpoint_open_other.go create mode 100644 cmd/mpc-ceremony/checkpoint_open_unix.go create mode 100644 internal/mpcceremony/checkpoint.go create mode 100644 internal/mpcceremony/checkpoint_test.go create mode 100644 internal/mpcceremony/submission.go create mode 100644 internal/mpcceremony/submission_test.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go new file mode 100644 index 00000000..1bc91184 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -0,0 +1,1914 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "errors" + "flag" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "proof-tool/internal/keybundle" + "proof-tool/internal/mpcceremony" +) + +const checkpointEvidenceInspectionSchema = "proof-tool-mpc-checkpoint-evidence-inspection-v1" + +type builtCheckpointEvidence struct { + trusted *mpcceremony.TrustedCeremony + checkpoint mpcceremony.Checkpoint + canonical []byte + request mpcceremony.CheckpointSigningRequest +} + +func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing checkpoint command", topic: []string{"checkpoint"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"checkpoint"}, args[1:]...)} + } + switch args[0] { + case "prepare": + options, err := parseCheckpointPrepare(args[1:]) + invocation.Command, invocation.Options = CommandCheckpointPrepare, options + return invocation, wrapCommandError(err, "checkpoint", "prepare") + case "sign": + options, err := parseCheckpointSign(args[1:]) + invocation.Command, invocation.Options = CommandCheckpointSign, options + return invocation, wrapCommandError(err, "checkpoint", "sign") + case "verify": + options, err := parseCheckpointVerify(args[1:]) + invocation.Command, invocation.Options = CommandCheckpointVerify, options + return invocation, wrapCommandError(err, "checkpoint", "verify") + case "verify-stored": + options, err := parseCheckpointVerifyStored(args[1:]) + invocation.Command, invocation.Options = CommandCheckpointVerifyStored, options + return invocation, wrapCommandError(err, "checkpoint", "verify-stored") + default: + return Invocation{}, &usageError{message: fmt.Sprintf("unknown checkpoint command %q", args[0]), topic: []string{"checkpoint"}} + } +} + +func addCheckpointEvidenceFlags(fs *flag.FlagSet, options *CheckpointEvidenceOptions) { + fs.StringVar(&options.CeremonyPath, "ceremony", "", "signed ceremony definition") + fs.StringVar(&options.CeremonySignaturePath, "ceremony-signature", "", "detached ceremony signature") + fs.StringVar(&options.CoordinatorPublicKeyFile, "coordinator-public-key-file", "", "independently authenticated coordinator public key") + fs.StringVar(&options.ArtifactRoot, "artifact-root", "", "root containing every referenced immutable public artifact") + fs.StringVar(&options.RelayReleaseID, "relay-release-id", "", "approved Relay release identity") + fs.StringVar(&options.TransitionKind, "transition", "", "supported authenticated ceremony checkpoint transition") + fs.StringVar(&options.PreviousCheckpointPath, "previous-checkpoint", "", "exact previous checkpoint for a noninitial transition") + fs.StringVar(&options.PreviousCheckpointSignaturePath, "previous-checkpoint-signature", "", "detached previous checkpoint signature") + fs.StringVar(&options.ChainPath, "chain", "", "exact current phase1 chain") + fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached current chain signature") + fs.StringVar(&options.HeadPayloadPath, "head-payload", "", "exact current phase1 head payload") + fs.StringVar(&options.Phase2GenesisPath, "phase2-genesis", "", "exact deterministic phase2 genesis payload") + fs.StringVar(&options.Phase2ChainPath, "phase2-chain", "", "exact current phase2 chain") + fs.StringVar(&options.Phase2ChainSignaturePath, "phase2-chain-signature", "", "detached current phase2 chain signature") + fs.StringVar(&options.Phase2HeadPayloadPath, "phase2-head-payload", "", "exact current phase2 head payload") + fs.StringVar(&options.TransitionRecordPath, "transition-record", "", "signed record that causes the transition") + fs.StringVar(&options.TransitionRecordSignaturePath, "transition-record-signature", "", "detached transition record signature") + fs.StringVar(&options.AcknowledgementPath, "acknowledgement", "", "signed accepted submission acknowledgement") + fs.StringVar(&options.AcknowledgementSignaturePath, "acknowledgement-signature", "", "detached acknowledgement signature") + fs.StringVar(&options.ManifestPath, "manifest", "", "exact submission transport manifest") + fs.StringVar(&options.AttemptID, "attempt-id", "", "preallocated receipt attempt ID") + fs.StringVar(&options.ManifestKey, "manifest-key", "", "preallocated receipt manifest key") + fs.StringVar(&options.NextAttemptID, "next-attempt-id", "", "preallocated candidate attempt ID") + fs.StringVar(&options.NextManifestKey, "next-manifest-key", "", "preallocated candidate manifest key") + fs.StringVar(&options.CandidateDir, "candidate-dir", "", "exact closed finalized candidate directory") + fs.StringVar(&options.ReleaseDir, "release-dir", "", "exact closed signed final release directory") +} + +func validateCheckpointEvidenceOptions(options CheckpointEvidenceOptions) error { + if err := requireValues( + pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), pathValue("--artifact-root", options.ArtifactRoot), + value("--relay-release-id", options.RelayReleaseID), value("--transition", options.TransitionKind), + pathValue("--chain", options.ChainPath), pathValue("--chain-signature", options.ChainSignaturePath), + pathValue("--head-payload", options.HeadPayloadPath), + ); err != nil { + return err + } + kind := mpcceremony.CheckpointTransitionKind(options.TransitionKind) + if kind != mpcceremony.CheckpointFinalCandidateRecorded && options.CandidateDir != "" { + return errors.New("--candidate-dir is permitted only for final-candidate-recorded") + } + if kind != mpcceremony.CheckpointFinalReleaseRecorded && options.ReleaseDir != "" { + return errors.New("--release-dir is permitted only for final-release-recorded") + } + switch kind { + case mpcceremony.CheckpointInitial: + if checkpointTransitionOnlyInputsPresent(options) { + return errors.New("initial checkpoint must not supply predecessor or transition-only inputs") + } + return nil + case mpcceremony.CheckpointPhase1OutboundPublished: + if checkpointPhase2TurnInputsPresent(options) { + return errors.New("phase1 checkpoint must not supply phase2 chain inputs") + } + if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || options.NextAttemptID != "" || options.NextManifestKey != "" { + return errors.New("outbound checkpoint must not supply acknowledgement, submission manifest, or next-attempt inputs") + } + return requireValues( + pathValue("--previous-checkpoint", options.PreviousCheckpointPath), pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + value("--attempt-id", options.AttemptID), value("--manifest-key", options.ManifestKey), + ) + case mpcceremony.CheckpointPhase1ReceiptAccepted: + if checkpointPhase2TurnInputsPresent(options) { + return errors.New("phase1 checkpoint must not supply phase2 chain inputs") + } + return requireValues( + pathValue("--previous-checkpoint", options.PreviousCheckpointPath), pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + pathValue("--acknowledgement", options.AcknowledgementPath), pathValue("--acknowledgement-signature", options.AcknowledgementSignaturePath), + pathValue("--manifest", options.ManifestPath), value("--next-attempt-id", options.NextAttemptID), value("--next-manifest-key", options.NextManifestKey), + ) + case mpcceremony.CheckpointPhase1CandidateAccepted: + if checkpointPhase2TurnInputsPresent(options) { + return errors.New("phase1 checkpoint must not supply phase2 chain inputs") + } + if options.AttemptID != "" || options.ManifestKey != "" || options.NextAttemptID != "" || options.NextManifestKey != "" { + return errors.New("candidate-accepted checkpoint derives its allocated attempt and must not supply attempt flags") + } + return requireValues( + pathValue("--previous-checkpoint", options.PreviousCheckpointPath), pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + pathValue("--acknowledgement", options.AcknowledgementPath), pathValue("--acknowledgement-signature", options.AcknowledgementSignaturePath), + pathValue("--manifest", options.ManifestPath), + ) + case mpcceremony.CheckpointPhase1Closed: + if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || + options.AttemptID != "" || options.ManifestKey != "" || options.NextAttemptID != "" || options.NextManifestKey != "" { + return errors.New("phase1 closure checkpoint must not supply submission or attempt inputs") + } + return requireValues( + pathValue("--previous-checkpoint", options.PreviousCheckpointPath), pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + ) + case mpcceremony.CheckpointPhase1BeaconRecorded, mpcceremony.CheckpointPhase1Sealed: + if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || + options.AttemptID != "" || options.ManifestKey != "" || options.NextAttemptID != "" || options.NextManifestKey != "" { + return errors.New("phase1 closure/beacon/seal checkpoint must not supply submission or attempt inputs") + } + return requireValues( + pathValue("--previous-checkpoint", options.PreviousCheckpointPath), pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + ) + case mpcceremony.CheckpointPhase2Initialized: + if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || + options.AttemptID != "" || options.ManifestKey != "" || options.NextAttemptID != "" || options.NextManifestKey != "" { + return errors.New("phase2 initialization checkpoint must not supply submission or attempt inputs") + } + return requireValues( + pathValue("--previous-checkpoint", options.PreviousCheckpointPath), pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + pathValue("--phase2-genesis", options.Phase2GenesisPath), + ) + case mpcceremony.CheckpointPhase2OutboundPublished: + if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || options.NextAttemptID != "" || options.NextManifestKey != "" || options.Phase2GenesisPath != "" { + return errors.New("phase2 outbound checkpoint must not supply acknowledgement, submission manifest, next-attempt, or genesis inputs") + } + return requireCheckpointPhase2TurnValues(options, + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + value("--attempt-id", options.AttemptID), value("--manifest-key", options.ManifestKey)) + case mpcceremony.CheckpointPhase2ReceiptAccepted: + if options.Phase2GenesisPath != "" { + return errors.New("phase2 receipt checkpoint must not supply a genesis input") + } + return requireCheckpointPhase2TurnValues(options, + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + pathValue("--acknowledgement", options.AcknowledgementPath), pathValue("--acknowledgement-signature", options.AcknowledgementSignaturePath), + pathValue("--manifest", options.ManifestPath), value("--next-attempt-id", options.NextAttemptID), value("--next-manifest-key", options.NextManifestKey)) + case mpcceremony.CheckpointPhase2CandidateAccepted: + if options.AttemptID != "" || options.ManifestKey != "" || options.NextAttemptID != "" || options.NextManifestKey != "" || options.Phase2GenesisPath != "" { + return errors.New("phase2 candidate checkpoint derives its allocated attempt and must not supply attempt or genesis flags") + } + return requireCheckpointPhase2TurnValues(options, + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath), + pathValue("--acknowledgement", options.AcknowledgementPath), pathValue("--acknowledgement-signature", options.AcknowledgementSignaturePath), + pathValue("--manifest", options.ManifestPath)) + case mpcceremony.CheckpointPhase2Closed, mpcceremony.CheckpointPhase2BeaconRecorded: + if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || + options.AttemptID != "" || options.ManifestKey != "" || options.NextAttemptID != "" || options.NextManifestKey != "" || options.Phase2GenesisPath != "" { + return errors.New("phase2 closure/beacon checkpoint must not supply submission, attempt, or genesis inputs") + } + return requireCheckpointPhase2TurnValues(options, + pathValue("--transition-record", options.TransitionRecordPath), pathValue("--transition-record-signature", options.TransitionRecordSignaturePath)) + case mpcceremony.CheckpointFinalCandidateRecorded: + if options.TransitionRecordPath != "" || options.TransitionRecordSignaturePath != "" || options.AcknowledgementPath != "" || + options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || options.AttemptID != "" || options.ManifestKey != "" || + options.NextAttemptID != "" || options.NextManifestKey != "" || options.Phase2GenesisPath != "" { + return errors.New("final candidate checkpoint derives its record and inventory from candidate-dir and must not supply submission inputs") + } + return requireCheckpointPhase2TurnValues(options, pathValue("--candidate-dir", options.CandidateDir)) + case mpcceremony.CheckpointFinalReleaseRecorded: + if options.TransitionRecordPath != "" || options.TransitionRecordSignaturePath != "" || options.AcknowledgementPath != "" || + options.AcknowledgementSignaturePath != "" || options.ManifestPath != "" || options.AttemptID != "" || options.ManifestKey != "" || + options.NextAttemptID != "" || options.NextManifestKey != "" || options.Phase2GenesisPath != "" { + return errors.New("final release checkpoint derives its record and inventory from release-dir and must not supply submission inputs") + } + return requireCheckpointPhase2TurnValues(options, pathValue("--release-dir", options.ReleaseDir)) + default: + return fmt.Errorf("unsupported guarded checkpoint transition %q", kind) + } +} + +func checkpointPhase2TurnInputsPresent(options CheckpointEvidenceOptions) bool { + return options.Phase2ChainPath != "" || options.Phase2ChainSignaturePath != "" || options.Phase2HeadPayloadPath != "" +} + +func requireCheckpointPhase2TurnValues(options CheckpointEvidenceOptions, values ...requiredValue) error { + base := []requiredValue{ + pathValue("--previous-checkpoint", options.PreviousCheckpointPath), pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--phase2-chain", options.Phase2ChainPath), pathValue("--phase2-chain-signature", options.Phase2ChainSignaturePath), + pathValue("--phase2-head-payload", options.Phase2HeadPayloadPath), + } + return requireValues(append(base, values...)...) +} + +func checkpointTransitionOnlyInputsPresent(options CheckpointEvidenceOptions) bool { + return options.PreviousCheckpointPath != "" || options.PreviousCheckpointSignaturePath != "" || + options.TransitionRecordPath != "" || options.TransitionRecordSignaturePath != "" || + options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" || + options.ManifestPath != "" || options.AttemptID != "" || options.ManifestKey != "" || + options.NextAttemptID != "" || options.NextManifestKey != "" || options.Phase2GenesisPath != "" || + options.CandidateDir != "" || options.ReleaseDir != "" || + checkpointPhase2TurnInputsPresent(options) +} + +func parseCheckpointPrepare(args []string) (CheckpointPrepareOptions, error) { + var options CheckpointPrepareOptions + fs := commandFlagSet("checkpoint prepare") + addCheckpointEvidenceFlags(fs, &options.CheckpointEvidenceOptions) + fs.StringVar(&options.OutDir, "out-dir", "", "fresh checkpoint signing packet directory") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := validateCheckpointEvidenceOptions(options.CheckpointEvidenceOptions); err != nil { + return options, err + } + return options, requireValues(pathValue("--out-dir", options.OutDir)) +} + +func parseCheckpointSign(args []string) (CheckpointSignOptions, error) { + var options CheckpointSignOptions + fs := commandFlagSet("checkpoint sign") + addCheckpointEvidenceFlags(fs, &options.CheckpointEvidenceOptions) + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "exact reviewed checkpoint") + fs.StringVar(&options.SigningRequestPath, "signing-request", "", "exact checkpoint signing request") + fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") + fs.StringVar(&options.OutPath, "out", "", "fresh detached checkpoint signature") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := validateCheckpointEvidenceOptions(options.CheckpointEvidenceOptions); err != nil { + return options, err + } + return options, requireValues(pathValue("--checkpoint", options.CheckpointPath), pathValue("--signing-request", options.SigningRequestPath), pathValue("--coordinator-signing-key", options.CoordinatorSigningKey), pathValue("--out", options.OutPath)) +} + +func parseCheckpointVerify(args []string) (CheckpointVerifyOptions, error) { + var options CheckpointVerifyOptions + fs := commandFlagSet("checkpoint verify") + addCheckpointEvidenceFlags(fs, &options.CheckpointEvidenceOptions) + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "exact checkpoint") + fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "detached checkpoint signature") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := validateCheckpointEvidenceOptions(options.CheckpointEvidenceOptions); err != nil { + return options, err + } + return options, requireValues(pathValue("--checkpoint", options.CheckpointPath), pathValue("--checkpoint-signature", options.CheckpointSignaturePath)) +} + +func parseCheckpointVerifyStored(args []string) (CheckpointVerifyStoredOptions, error) { + var options CheckpointVerifyStoredOptions + fs := commandFlagSet("checkpoint verify-stored") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.ArtifactRoot, "artifact-root", "", "root containing the fetched immutable checkpoint graph") + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "exact target checkpoint") + fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "detached target checkpoint 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("--artifact-root", options.ArtifactRoot), + pathValue("--checkpoint", options.CheckpointPath), pathValue("--checkpoint-signature", options.CheckpointSignaturePath), + ) +} + +func executeCheckpointPrepare(options CheckpointPrepareOptions) (CommandResult, error) { + built, err := buildCheckpointEvidence(options.CheckpointEvidenceOptions) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(built.request) + if err != nil { + return CommandResult{}, err + } + checkpointPath, requestPath, err := writeOperationalSigningExport(options.OutDir, built.canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: built.checkpoint.CeremonyID, + Summary: fmt.Sprintf("prepared checkpoint %d from authenticated lifecycle evidence; review before signing", built.checkpoint.Sequence), + Outputs: map[string]string{ + "checkpoint": checkpointPath, "signing_request": requestPath, + }, + }, nil +} + +func executeCheckpointSign(options CheckpointSignOptions) (CommandResult, error) { + built, checkpointBytes, err := loadExactBuiltCheckpoint(options.CheckpointEvidenceOptions, options.CheckpointPath) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := readRegularOperationalFile(options.SigningRequestPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + var request mpcceremony.CheckpointSigningRequest + if err := mpcceremony.UnmarshalCanonical(requestBytes, &request); err != nil { + return CommandResult{}, fmt.Errorf("checkpoint signing request: %w", err) + } + if request != built.request { + return CommandResult{}, errors.New("checkpoint signing request does not bind the exact re-derived checkpoint") + } + key, publicKey, err := keybundle.LoadExistingPrivateKey(options.CoordinatorSigningKey) + if err != nil { + return CommandResult{}, err + } + if hex.EncodeToString(publicKey) != built.trusted.Definition.Coordinator.Ed25519PublicKeyHex { + return CommandResult{}, errors.New("checkpoint signing key does not match the authenticated coordinator") + } + _, signatureBytes, err := mpcceremony.SignRecord( + built.checkpoint, + built.trusted.Definition.Coordinator.KeyID, + key, + ) + if err != nil { + return CommandResult{}, err + } + if !bytes.Equal(checkpointBytes, built.canonical) { + return CommandResult{}, errors.New("checkpoint changed after evidence validation") + } + if err := writeFreshOperationalFile(options.OutPath, signatureBytes, 0o600); err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: built.checkpoint.CeremonyID, + Summary: fmt.Sprintf("signed fully re-derived checkpoint %d", built.checkpoint.Sequence), + Outputs: map[string]string{"checkpoint": options.CheckpointPath, "signature": options.OutPath}, + }, nil +} + +func executeCheckpointVerify(options CheckpointVerifyOptions) (CommandResult, error) { + built, checkpointBytes, err := loadExactBuiltCheckpoint(options.CheckpointEvidenceOptions, options.CheckpointPath) + if err != nil { + return CommandResult{}, err + } + _, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + signatureBytes, err := readRegularOperationalFile(options.CheckpointSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + if _, err := mpcceremony.VerifySignedCheckpoint( + built.trusted.Definition, definitionBytes, definitionSignatureBytes, checkpointBytes, signatureBytes, + ); err != nil { + return CommandResult{}, err + } + inspection := CheckpointEvidenceInspection{ + Schema: checkpointEvidenceInspectionSchema, CeremonyID: built.checkpoint.CeremonyID, + Sequence: built.checkpoint.Sequence, CheckpointDigest: mpcceremony.NewDigest(checkpointBytes), + TransitionKind: built.checkpoint.Transition.Kind, FullyVerified: true, + VerifiedEvidenceBoundary: checkpointEvidenceBoundary(built.checkpoint.Transition.Kind, false), + } + return CommandResult{ + CeremonyID: built.checkpoint.CeremonyID, + Summary: fmt.Sprintf("fully authenticated ceremony checkpoint %d", built.checkpoint.Sequence), + CheckpointEvidenceInspection: &inspection, + }, nil +} + +func executeCheckpointVerifyStored(options CheckpointVerifyStoredOptions) (CommandResult, error) { + checkpoint, checkpointBytes, err := verifyStoredCheckpointAncestry(options, options.CheckpointPath, options.CheckpointSignaturePath, make(map[string]struct{}), 0) + if err != nil { + return CommandResult{}, err + } + inspection := CheckpointEvidenceInspection{ + Schema: checkpointEvidenceInspectionSchema, CeremonyID: checkpoint.CeremonyID, + Sequence: checkpoint.Sequence, CheckpointDigest: mpcceremony.NewDigest(checkpointBytes), + TransitionKind: checkpoint.Transition.Kind, FullyVerified: true, + VerifiedEvidenceBoundary: checkpointEvidenceBoundary(checkpoint.Transition.Kind, true), + } + return CommandResult{ + CeremonyID: checkpoint.CeremonyID, + Summary: fmt.Sprintf("fully authenticated stored checkpoint ancestry through sequence %d", checkpoint.Sequence), + CheckpointEvidenceInspection: &inspection, + }, nil +} + +func checkpointEvidenceBoundary(kind mpcceremony.CheckpointTransitionKind, stored bool) string { + prefix := "authenticated checkpoint" + if stored { + prefix = "complete fetched checkpoint ancestry" + } + if kind == mpcceremony.CheckpointFinalReleaseRecorded { + return prefix + " through the signed final release: every ceremony transition and the exact closed release tree are authenticated; publication and GO/NO-GO approval are separate" + } + if kind == mpcceremony.CheckpointFinalCandidateRecorded { + return prefix + " through the finalized candidate: every transition is re-derived from exact signed records; both phases, cleanup, closure, beacon, sealed commons, deterministic Phase 2 genesis, and the closed candidate file inventory are fully replayed" + } + return prefix + " through " + string(kind) + ": every transition is re-derived from exact signed records, including full accepted-contribution replay and every lifecycle record reached so far" +} + +func verifyStoredCheckpointAncestry(options CheckpointVerifyStoredOptions, checkpointPath, signaturePath string, seen map[string]struct{}, depth int) (mpcceremony.Checkpoint, []byte, error) { + if depth > mpcceremony.MaxCheckpointAncestry { + return mpcceremony.Checkpoint{}, nil, fmt.Errorf("checkpoint ancestry exceeds the supported %d-edge bound", mpcceremony.MaxCheckpointAncestry) + } + trusted, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return mpcceremony.Checkpoint{}, nil, err + } + checkpoint, checkpointBytes, _, err := loadInspectionCheckpoint( + trusted.Definition, definitionBytes, definitionSignatureBytes, checkpointPath, signaturePath, + ) + if err != nil { + return mpcceremony.Checkpoint{}, nil, err + } + digest := mpcceremony.NewDigest(checkpointBytes).SHA256 + if _, exists := seen[digest]; exists { + return mpcceremony.Checkpoint{}, nil, errors.New("checkpoint ancestry contains a cycle") + } + seen[digest] = struct{}{} + defer delete(seen, digest) + + evidence, err := inferStoredCheckpointEvidence(options, checkpoint) + if err != nil { + return mpcceremony.Checkpoint{}, nil, err + } + if checkpoint.PreviousCheckpoint != nil { + previousPath := filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.PreviousCheckpoint.Record.Name)) + previousSignaturePath := filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.PreviousCheckpoint.Signature.Name)) + if _, _, err := verifyStoredCheckpointAncestry(options, previousPath, previousSignaturePath, seen, depth+1); err != nil { + return mpcceremony.Checkpoint{}, nil, fmt.Errorf("checkpoint %d predecessor: %w", checkpoint.Sequence, err) + } + } + if checkpoint.Phase2 != nil { + evidence.Phase2ChainPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Phase2.Chain.Record.Name)) + evidence.Phase2ChainSignaturePath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Phase2.Chain.Signature.Name)) + evidence.Phase2HeadPayloadPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Phase2.HeadPayload.Name)) + } + // The recursive walk above already fully re-derived the predecessor. + built, err := buildCheckpointEvidenceWithParent(evidence, false) + if err != nil { + return mpcceremony.Checkpoint{}, nil, fmt.Errorf("checkpoint %d evidence: %w", checkpoint.Sequence, err) + } + if !bytes.Equal(built.canonical, checkpointBytes) { + return mpcceremony.Checkpoint{}, nil, fmt.Errorf("checkpoint %d differs from its fully re-derived evidence", checkpoint.Sequence) + } + return checkpoint, checkpointBytes, nil +} + +func inferStoredCheckpointEvidence(options CheckpointVerifyStoredOptions, checkpoint mpcceremony.Checkpoint) (CheckpointEvidenceOptions, error) { + evidence := CheckpointEvidenceOptions{ + InspectDefinitionOptions: options.InspectDefinitionOptions, + ArtifactRoot: options.ArtifactRoot, RelayReleaseID: checkpoint.RelayReleaseID, + TransitionKind: string(checkpoint.Transition.Kind), + ChainPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Phase1.Chain.Record.Name)), + ChainSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Phase1.Chain.Signature.Name)), + HeadPayloadPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Phase1.HeadPayload.Name)), + } + if checkpoint.PreviousCheckpoint != nil { + evidence.PreviousCheckpointPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.PreviousCheckpoint.Record.Name)) + evidence.PreviousCheckpointSignaturePath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.PreviousCheckpoint.Signature.Name)) + } + if checkpoint.Transition.Record != nil { + evidence.TransitionRecordPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Transition.Record.Record.Name)) + evidence.TransitionRecordSignaturePath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Transition.Record.Signature.Name)) + } + if checkpoint.Transition.Acknowledgement != nil { + evidence.AcknowledgementPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Transition.Acknowledgement.Record.Name)) + evidence.AcknowledgementSignaturePath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Transition.Acknowledgement.Signature.Name)) + ackBytes, err := checkpointBytesForRef(options.ArtifactRoot, checkpoint.Transition.Acknowledgement.Record, maxOperationalRecordBytes) + if err != nil { + return CheckpointEvidenceOptions{}, err + } + var acknowledgement mpcceremony.SubmissionAcknowledgementV1 + if err := mpcceremony.UnmarshalCanonical(ackBytes, &acknowledgement); err != nil { + return CheckpointEvidenceOptions{}, err + } + evidence.ManifestPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(acknowledgement.Manifest.Name)) + } + switch checkpoint.Transition.Kind { + case mpcceremony.CheckpointInitial: + case mpcceremony.CheckpointPhase1OutboundPublished, mpcceremony.CheckpointPhase2OutboundPublished: + for _, slot := range checkpoint.Submissions { + if slot.Kind == mpcceremony.CheckpointSubmissionReceipt && slot.AttemptID == checkpoint.Transition.AttemptID { + evidence.AttemptID, evidence.ManifestKey = slot.AttemptID, slot.ManifestKey + break + } + } + case mpcceremony.CheckpointPhase1ReceiptAccepted, mpcceremony.CheckpointPhase2ReceiptAccepted: + for _, slot := range checkpoint.Submissions { + if slot.Kind == mpcceremony.CheckpointSubmissionCandidate && slot.AttemptID == checkpoint.Transition.NextAttemptID { + evidence.NextAttemptID, evidence.NextManifestKey = slot.AttemptID, slot.ManifestKey + break + } + } + case mpcceremony.CheckpointPhase1CandidateAccepted, mpcceremony.CheckpointPhase2CandidateAccepted: + case mpcceremony.CheckpointPhase1Closed: + case mpcceremony.CheckpointPhase1BeaconRecorded: + case mpcceremony.CheckpointPhase1Sealed: + case mpcceremony.CheckpointPhase2Initialized: + if checkpoint.Phase2 == nil { + return CheckpointEvidenceOptions{}, errors.New("stored phase2 initialization checkpoint has no phase2 state") + } + evidence.Phase2GenesisPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.Phase2.HeadPayload.Name)) + case mpcceremony.CheckpointPhase2Closed, mpcceremony.CheckpointPhase2BeaconRecorded: + case mpcceremony.CheckpointFinalCandidateRecorded: + if checkpoint.FinalCandidate == nil { + return CheckpointEvidenceOptions{}, errors.New("stored final candidate checkpoint has no final candidate") + } + evidence.CandidateDir = filepath.Dir(filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.FinalCandidate.Record.Name))) + case mpcceremony.CheckpointFinalReleaseRecorded: + if checkpoint.FinalRelease == nil { + return CheckpointEvidenceOptions{}, errors.New("stored final release checkpoint has no final release") + } + evidence.ReleaseDir = filepath.Dir(filepath.Join(options.ArtifactRoot, filepath.FromSlash(checkpoint.FinalRelease.Record.Name))) + default: + return CheckpointEvidenceOptions{}, fmt.Errorf("stored checkpoint transition %q is outside the supported authenticated lifecycle boundary", checkpoint.Transition.Kind) + } + return evidence, nil +} + +func loadExactBuiltCheckpoint(options CheckpointEvidenceOptions, checkpointPath string) (builtCheckpointEvidence, []byte, error) { + built, err := buildCheckpointEvidence(options) + if err != nil { + return builtCheckpointEvidence{}, nil, err + } + checkpointBytes, err := readRegularOperationalFile(checkpointPath, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, nil, err + } + if !bytes.Equal(checkpointBytes, built.canonical) { + return builtCheckpointEvidence{}, nil, errors.New("checkpoint bytes do not equal the checkpoint re-derived from authenticated evidence") + } + return built, checkpointBytes, nil +} + +func buildCheckpointEvidence(options CheckpointEvidenceOptions) (builtCheckpointEvidence, error) { + return buildCheckpointEvidenceWithParent(options, true) +} + +func buildCheckpointEvidenceWithParent(options CheckpointEvidenceOptions, verifyParent bool) (builtCheckpointEvidence, error) { + trusted, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return builtCheckpointEvidence{}, err + } + definitionRefs, err := checkpointPairRefs(options.ArtifactRoot, options.CeremonyPath, options.CeremonySignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("definition references: %w", err) + } + if definitionRefs.Record.Digest != mpcceremony.NewDigest(definitionBytes) || definitionRefs.Signature.Digest != mpcceremony.NewDigest(definitionSignatureBytes) { + return builtCheckpointEvidence{}, errors.New("definition reference bytes changed during validation") + } + chainPaths := mpcceremony.PhaseTranscriptPaths{ + RootDir: options.ArtifactRoot, ChainPath: options.ChainPath, ChainSignaturePath: options.ChainSignaturePath, + } + chain, chainRefs, err := mpcceremony.LoadSignedChainExact(trusted, chainPaths) + if err != nil { + return builtCheckpointEvidence{}, err + } + if chain.Phase != mpcceremony.Phase1 { + return builtCheckpointEvidence{}, errors.New("checkpoint foundation currently supports phase1 only") + } + expectedChainName := fmt.Sprintf("phase1/chain-%04d.json", len(chain.Records)) + if err := requireCheckpointArtifactName(chainRefs.Record, expectedChainName, "phase1 chain"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(chainRefs.Signature, strings.TrimSuffix(expectedChainName, ".json")+".sig", "phase1 chain signature"); err != nil { + return builtCheckpointEvidence{}, err + } + headPayload, err := chain.HeadPayload() + if err != nil { + return builtCheckpointEvidence{}, err + } + suppliedHead, err := checkpointArtifactRef(options.ArtifactRoot, options.HeadPayloadPath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("head payload: %w", err) + } + if suppliedHead != headPayload { + return builtCheckpointEvidence{}, errors.New("head payload does not match the authenticated chain head") + } + headID, err := chain.HeadRecordID() + if err != nil { + return builtCheckpointEvidence{}, err + } + phaseState := mpcceremony.CheckpointPhaseState{ + Phase: mpcceremony.Phase1, AcceptedCount: uint8(len(chain.Records)), HeadRecordID: headID, + HeadPayload: headPayload, Chain: chainRefs, + } + + kind := mpcceremony.CheckpointTransitionKind(options.TransitionKind) + var circuit *mpcceremony.CompiledCircuit + if kind == mpcceremony.CheckpointPhase1CandidateAccepted || kind == mpcceremony.CheckpointPhase1Sealed || kind == mpcceremony.CheckpointPhase2Initialized || kind == mpcceremony.CheckpointPhase2CandidateAccepted || kind == mpcceremony.CheckpointPhase2Closed || kind == mpcceremony.CheckpointFinalCandidateRecorded { + r1csPath := filepath.Join(options.ArtifactRoot, filepath.FromSlash(trusted.Definition.Circuit.R1CS.Name)) + rootAbs, rootErr := filepath.Abs(options.ArtifactRoot) + if rootErr != nil { + return builtCheckpointEvidence{}, rootErr + } + r1csAbs, pathErr := filepath.Abs(r1csPath) + if pathErr != nil { + return builtCheckpointEvidence{}, pathErr + } + if pathErr = validateCheckpointPathComponents(rootAbs, r1csAbs); pathErr != nil { + return builtCheckpointEvidence{}, fmt.Errorf("signed circuit artifact: %w", pathErr) + } + circuit, err = mpcceremony.ReadR1CSFile(r1csPath, trusted.Definition.Circuit) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("signed circuit artifact: %w", err) + } + } + if kind == mpcceremony.CheckpointPhase1CandidateAccepted { + chain, chainRefs, err = mpcceremony.VerifyAcceptedPhase1Chain( + trustPaths(options.CeremonyPath, options.CeremonySignaturePath, options.CoordinatorPublicKeyFile), circuit, chainPaths, + ) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("full candidate chain verification: %w", err) + } + headPayload, err = chain.HeadPayload() + if err != nil { + return builtCheckpointEvidence{}, err + } + headID, err = chain.HeadRecordID() + if err != nil { + return builtCheckpointEvidence{}, err + } + phaseState = mpcceremony.CheckpointPhaseState{ + Phase: mpcceremony.Phase1, AcceptedCount: uint8(len(chain.Records)), HeadRecordID: headID, + HeadPayload: headPayload, Chain: chainRefs, + } + } + if kind == mpcceremony.CheckpointInitial { + if len(chain.Records) != 0 { + return builtCheckpointEvidence{}, errors.New("initial checkpoint requires the authenticated phase1 genesis chain") + } + checkpoint := mpcceremony.Checkpoint{ + Schema: checkpointSchemaForDefinition(trusted.Definition), Workflow: mpcceremony.StorageFirstWorkflowV1, + CeremonyID: trusted.Definition.CeremonyID, Definition: definitionRefs, + AssurancePolicy: trusted.Definition.AssurancePolicy, + RelayReleaseID: options.RelayReleaseID, Sequence: 0, + Transition: mpcceremony.CheckpointTransition{Kind: mpcceremony.CheckpointInitial}, Phase1: phaseState, + AcceptedArtifacts: checkpointSortedArtifacts(definitionRefs.Record, definitionRefs.Signature, headPayload, chainRefs.Record, chainRefs.Signature), + Submissions: []mpcceremony.CheckpointSubmissionSlot{}, + } + return finishBuiltCheckpoint(trusted, checkpoint) + } + if verifyParent { + if _, _, err := verifyStoredCheckpointAncestry( + CheckpointVerifyStoredOptions{ + InspectCheckpointOptions: InspectCheckpointOptions{ + InspectDefinitionOptions: options.InspectDefinitionOptions, + CheckpointPath: options.PreviousCheckpointPath, + CheckpointSignaturePath: options.PreviousCheckpointSignaturePath, + }, + ArtifactRoot: options.ArtifactRoot, + }, + options.PreviousCheckpointPath, + options.PreviousCheckpointSignaturePath, + make(map[string]struct{}), 0, + ); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("previous checkpoint ancestry: %w", err) + } + } + + previous, previousBytes, previousSignatureBytes, err := loadInspectionCheckpoint( + trusted.Definition, definitionBytes, definitionSignatureBytes, + options.PreviousCheckpointPath, options.PreviousCheckpointSignaturePath, + ) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("previous checkpoint: %w", err) + } + previousRefs, err := checkpointPairRefs(options.ArtifactRoot, options.PreviousCheckpointPath, options.PreviousCheckpointSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("previous checkpoint references: %w", err) + } + if previousRefs.Record.Digest != mpcceremony.NewDigest(previousBytes) || previousRefs.Signature.Digest != mpcceremony.NewDigest(previousSignatureBytes) { + return builtCheckpointEvidence{}, errors.New("previous checkpoint reference bytes changed during validation") + } + if kind != mpcceremony.CheckpointPhase1CandidateAccepted && previous.Phase1 != phaseState { + return builtCheckpointEvidence{}, errors.New("phase1 chain and head do not equal the previous checkpoint state") + } + if options.RelayReleaseID != previous.RelayReleaseID { + return builtCheckpointEvidence{}, errors.New("relay release id differs from previous checkpoint") + } + activePhase := mpcceremony.Phase1 + activeState := phaseState + activeChain := chain + if kind == mpcceremony.CheckpointPhase2OutboundPublished || kind == mpcceremony.CheckpointPhase2ReceiptAccepted || kind == mpcceremony.CheckpointPhase2CandidateAccepted || kind == mpcceremony.CheckpointPhase2Closed || kind == mpcceremony.CheckpointPhase2BeaconRecorded || kind == mpcceremony.CheckpointFinalCandidateRecorded || kind == mpcceremony.CheckpointFinalReleaseRecorded { + if previous.Phase2 == nil || previous.Phase1Seal == nil { + return builtCheckpointEvidence{}, errors.New("phase2 turn requires an initialized phase2 checkpoint") + } + activePhase = mpcceremony.Phase2 + phase2Paths := mpcceremony.PhaseTranscriptPaths{RootDir: options.ArtifactRoot, ChainPath: options.Phase2ChainPath, ChainSignaturePath: options.Phase2ChainSignaturePath} + var phase2Refs mpcceremony.SignedArtifactRefs + if kind == mpcceremony.CheckpointPhase2CandidateAccepted || kind == mpcceremony.CheckpointPhase2Closed { + activeChain, phase2Refs, err = mpcceremony.VerifyAcceptedPhase2Chain( + trustPaths(options.CeremonyPath, options.CeremonySignaturePath, options.CoordinatorPublicKeyFile), circuit, options.ArtifactRoot, + filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Seal.Record.Name)), + filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Seal.Signature.Name)), phase2Paths) + } else { + activeChain, phase2Refs, err = mpcceremony.LoadSignedChainExact(trusted, phase2Paths) + } + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 chain verification: %w", err) + } + expectedName := fmt.Sprintf("phase2/chain-%04d.json", len(activeChain.Records)) + if err := requireCheckpointArtifactName(phase2Refs.Record, expectedName, "phase2 chain"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(phase2Refs.Signature, strings.TrimSuffix(expectedName, ".json")+".sig", "phase2 chain signature"); err != nil { + return builtCheckpointEvidence{}, err + } + phase2Head, headErr := activeChain.HeadPayload() + if headErr != nil { + return builtCheckpointEvidence{}, headErr + } + supplied, refErr := checkpointArtifactRef(options.ArtifactRoot, options.Phase2HeadPayloadPath) + if refErr != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 head payload: %w", refErr) + } + if supplied != phase2Head { + return builtCheckpointEvidence{}, errors.New("phase2 head payload does not match the authenticated chain head") + } + phase2HeadID, headErr := activeChain.HeadRecordID() + if headErr != nil { + return builtCheckpointEvidence{}, headErr + } + activeState = mpcceremony.CheckpointPhaseState{Phase: mpcceremony.Phase2, AcceptedCount: uint8(len(activeChain.Records)), HeadRecordID: phase2HeadID, HeadPayload: phase2Head, Chain: phase2Refs} + if kind != mpcceremony.CheckpointPhase2CandidateAccepted && *previous.Phase2 != activeState { + return builtCheckpointEvidence{}, errors.New("phase2 chain and head do not equal the previous checkpoint state") + } + } + + switch kind { + case mpcceremony.CheckpointPhase1OutboundPublished: + return buildOutboundCheckpoint(options, trusted, previous, previousRefs, activeState, activePhase) + case mpcceremony.CheckpointPhase1ReceiptAccepted: + return buildReceiptCheckpoint(options, trusted, previous, previousRefs, activeState, activePhase) + case mpcceremony.CheckpointPhase1CandidateAccepted: + return buildCandidateCheckpoint(options, trusted, previous, previousRefs, activeState, activeChain, activePhase) + case mpcceremony.CheckpointPhase1Closed: + return buildPhase1ClosedCheckpoint(options, trusted, previous, previousRefs, phaseState, chain) + case mpcceremony.CheckpointPhase1BeaconRecorded: + return buildPhase1BeaconCheckpoint(options, trusted, previous, previousRefs, phaseState) + case mpcceremony.CheckpointPhase1Sealed: + return buildPhase1SealCheckpoint(options, trusted, previous, previousRefs, phaseState, circuit) + case mpcceremony.CheckpointPhase2Initialized: + return buildPhase2InitializedCheckpoint(options, trusted, previous, previousRefs, phaseState, circuit) + case mpcceremony.CheckpointPhase2OutboundPublished: + return buildOutboundCheckpoint(options, trusted, previous, previousRefs, activeState, activePhase) + case mpcceremony.CheckpointPhase2ReceiptAccepted: + return buildReceiptCheckpoint(options, trusted, previous, previousRefs, activeState, activePhase) + case mpcceremony.CheckpointPhase2CandidateAccepted: + return buildCandidateCheckpoint(options, trusted, previous, previousRefs, activeState, activeChain, activePhase) + case mpcceremony.CheckpointPhase2Closed: + return buildPhaseClosedCheckpoint(options, trusted, previous, previousRefs, activeState, activeChain, mpcceremony.Phase2) + case mpcceremony.CheckpointPhase2BeaconRecorded: + return buildPhaseBeaconCheckpoint(options, trusted, previous, previousRefs, activeState, mpcceremony.Phase2) + case mpcceremony.CheckpointFinalCandidateRecorded: + return buildFinalCandidateCheckpoint(options, trusted, previous, previousRefs, phaseState, activeState, circuit) + case mpcceremony.CheckpointFinalReleaseRecorded: + return buildFinalReleaseCheckpoint(options, trusted, previous, previousRefs, phaseState, activeState) + default: + return builtCheckpointEvidence{}, fmt.Errorf("unsupported guarded checkpoint transition %q", kind) + } +} + +func buildPhase2InitializedCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phase1State mpcceremony.CheckpointPhaseState, circuit *mpcceremony.CompiledCircuit) (builtCheckpointEvidence, error) { + if previous.Phase1Seal == nil || previous.Phase1Closure == nil || previous.Phase1Beacon == nil { + return builtCheckpointEvidence{}, errors.New("phase2 initialization requires a sealed phase1 checkpoint") + } + if previous.Phase2 != nil { + return builtCheckpointEvidence{}, errors.New("phase2 is already initialized in the previous checkpoint") + } + verified, err := mpcceremony.VerifyPhase2GenesisFiles(mpcceremony.VerifyPhase2GenesisFilesOptions{ + Trust: trustPaths(options.CeremonyPath, options.CeremonySignaturePath, options.CoordinatorPublicKeyFile), + Circuit: circuit, TranscriptRoot: options.ArtifactRoot, + Phase1SealPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Seal.Record.Name)), + Phase1SealSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Seal.Signature.Name)), + Phase2ChainPath: options.TransitionRecordPath, Phase2ChainSignaturePath: options.TransitionRecordSignaturePath, + }) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("full phase2 genesis verification: %w", err) + } + if err := requireCheckpointArtifactName(verified.ChainRefs.Record, "phase2/chain-0000.json", "phase2 genesis chain"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(verified.ChainRefs.Signature, "phase2/chain-0000.sig", "phase2 genesis chain signature"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(verified.Genesis, "phase2/genesis.bin", "phase2 genesis"); err != nil { + return builtCheckpointEvidence{}, err + } + suppliedGenesis, err := checkpointArtifactRef(options.ArtifactRoot, options.Phase2GenesisPath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 genesis: %w", err) + } + if suppliedGenesis != verified.Genesis { + return builtCheckpointEvidence{}, errors.New("phase2 genesis does not match the authenticated deterministic chain genesis") + } + headID, err := verified.Chain.HeadRecordID() + if err != nil { + return builtCheckpointEvidence{}, err + } + phase2State := mpcceremony.CheckpointPhaseState{Phase: mpcceremony.Phase2, AcceptedCount: 0, HeadRecordID: headID, HeadPayload: verified.Genesis, Chain: verified.ChainRefs} + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{Kind: mpcceremony.CheckpointPhase2Initialized, Phase: mpcceremony.Phase2, Record: &verified.ChainRefs, Evidence: []mpcceremony.ArtifactRef{verified.Genesis}} + checkpoint.Phase1 = phase1State + checkpoint.Phase2 = &phase2State + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), verified.ChainRefs.Record, verified.ChainRefs.Signature, verified.Genesis)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func buildPhaseClosedCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState, chain mpcceremony.Chain, phase mpcceremony.Phase) (builtCheckpointEvidence, error) { + if phase != mpcceremony.Phase2 || previous.Phase2 == nil || previous.Phase1Closure == nil { + return builtCheckpointEvidence{}, errors.New("phase2 closure requires initialized phase2 state") + } + if previous.Phase2Closure != nil { + return builtCheckpointEvidence{}, errors.New("phase2 is already closed in the previous checkpoint") + } + closeBytes, closeSignature, closeRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 closure: %w", err) + } + if err := requireCheckpointArtifactName(closeRefs.Record, "phase2/closure/record.json", "phase2 closure"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(closeRefs.Signature, "phase2/closure/record.sig", "phase2 closure signature"); err != nil { + return builtCheckpointEvidence{}, err + } + publicKey, err := keybundle.DecodePublicKeyHex(trusted.Definition.Coordinator.Ed25519PublicKeyHex) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("coordinator public key: %w", err) + } + var closeRecord mpcceremony.CloseRecord + if err := mpcceremony.VerifySignedRecord(closeBytes, closeSignature, &closeRecord, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 closure signature: %w", err) + } + if closeRecord.Phase != phase { + return builtCheckpointEvidence{}, errors.New("phase2-closed checkpoint received a non-phase2 closure") + } + if err := mpcceremony.ValidateClose(trusted.Definition, chain, closeRecord); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 closure: %w", err) + } + phase1CloseBytes, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Closure.Record, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure: %w", err) + } + phase1CloseSignature, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Closure.Signature, 4096) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure signature: %w", err) + } + var phase1Close mpcceremony.CloseRecord + if err := mpcceremony.VerifySignedRecord(phase1CloseBytes, phase1CloseSignature, &phase1Close, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure signature: %w", err) + } + if err := validateDistinctPhaseCloseRounds(phase1Close, closeRecord); err != nil { + return builtCheckpointEvidence{}, err + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{Kind: mpcceremony.CheckpointPhase2Closed, Phase: phase, Record: &closeRefs} + state := phaseState + checkpoint.Phase2 = &state + checkpoint.Phase2Closure = &closeRefs + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), closeRefs.Record, closeRefs.Signature)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func buildPhaseBeaconCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState, phase mpcceremony.Phase) (builtCheckpointEvidence, error) { + if phase != mpcceremony.Phase2 || previous.Phase2Closure == nil || previous.Phase1Beacon == nil { + return builtCheckpointEvidence{}, errors.New("phase2 beacon requires a closure in the previous checkpoint") + } + if previous.Phase2Beacon != nil { + return builtCheckpointEvidence{}, errors.New("phase2 beacon is already recorded in the previous checkpoint") + } + publicKey, err := keybundle.DecodePublicKeyHex(trusted.Definition.Coordinator.Ed25519PublicKeyHex) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("coordinator public key: %w", err) + } + closureBytes, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase2Closure.Record, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 closure: %w", err) + } + closureSignature, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase2Closure.Signature, 4096) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 closure signature: %w", err) + } + var closure mpcceremony.CloseRecord + if err := mpcceremony.VerifySignedRecord(closureBytes, closureSignature, &closure, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 closure signature: %w", err) + } + beaconBytes, beaconSignature, beaconRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 beacon: %w", err) + } + if err := requireCheckpointArtifactName(beaconRefs.Record, "phase2/beacon/record.json", "phase2 beacon"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(beaconRefs.Signature, "phase2/beacon/record.sig", "phase2 beacon signature"); err != nil { + return builtCheckpointEvidence{}, err + } + var beacon mpcceremony.BeaconRecord + if err := mpcceremony.VerifySignedRecord(beaconBytes, beaconSignature, &beacon, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 beacon signature: %w", err) + } + if beacon.Phase != phase { + return builtCheckpointEvidence{}, errors.New("phase2-beacon-recorded checkpoint received a non-phase2 beacon") + } + if err := mpcceremony.ValidateBeacon(trusted.Definition, closure, beacon); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 beacon: %w", err) + } + if err := mpcceremony.VerifyBeaconRecordFiles(trusted, options.ArtifactRoot, closure, beacon); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 beacon evidence: %w", err) + } + phase1BeaconBytes, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Beacon.Record, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon: %w", err) + } + phase1BeaconSignature, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Beacon.Signature, 4096) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon signature: %w", err) + } + var phase1Beacon mpcceremony.BeaconRecord + if err := mpcceremony.VerifySignedRecord(phase1BeaconBytes, phase1BeaconSignature, &phase1Beacon, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon signature: %w", err) + } + if err := validateDistinctPhaseBeaconRecords(phase1Beacon, beacon); err != nil { + return builtCheckpointEvidence{}, err + } + rawResponse, err := checkpointArtifactRef(options.ArtifactRoot, filepath.Join(options.ArtifactRoot, filepath.FromSlash(beacon.RawResponse.Name))) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase2 raw beacon response: %w", err) + } + if rawResponse != beacon.RawResponse { + return builtCheckpointEvidence{}, errors.New("phase2 raw beacon response changed during validation") + } + if err := requireCheckpointArtifactName(rawResponse, "phase2/beacon/raw-response.bin", "phase2 raw beacon response"); err != nil { + return builtCheckpointEvidence{}, err + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{Kind: mpcceremony.CheckpointPhase2BeaconRecorded, Phase: phase, Record: &beaconRefs, Evidence: []mpcceremony.ArtifactRef{rawResponse}} + state := phaseState + checkpoint.Phase2 = &state + checkpoint.Phase2Beacon = &beaconRefs + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), beaconRefs.Record, beaconRefs.Signature, rawResponse)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func validateDistinctPhaseCloseRounds(phase1, phase2 mpcceremony.CloseRecord) error { + if phase1.BeaconProvider == phase2.BeaconProvider && phase1.BeaconNetwork == phase2.BeaconNetwork && phase1.BeaconRound == phase2.BeaconRound { + return errors.New("phase1 and phase2 must use distinct beacon rounds") + } + return nil +} + +func validateDistinctPhaseBeaconRecords(phase1, phase2 mpcceremony.BeaconRecord) error { + if phase1.ChallengeSHA256 == phase2.ChallengeSHA256 || + (phase1.Provider == phase2.Provider && phase1.Network == phase2.Network && phase1.Round == phase2.Round) { + return errors.New("phase1 and phase2 must use distinct beacon challenges and rounds") + } + return nil +} + +func buildFinalCandidateCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phase1State, phase2State mpcceremony.CheckpointPhaseState, circuit *mpcceremony.CompiledCircuit) (builtCheckpointEvidence, error) { + if previous.Phase1Closure == nil || previous.Phase1Beacon == nil || previous.Phase1Seal == nil || previous.Phase2 == nil || previous.Phase2Closure == nil || previous.Phase2Beacon == nil { + return builtCheckpointEvidence{}, errors.New("final candidate requires both completed phases and the phase1 seal") + } + if previous.FinalCandidate != nil { + return builtCheckpointEvidence{}, errors.New("final candidate is already recorded in the previous checkpoint") + } + rootAbs, err := filepath.Abs(options.ArtifactRoot) + if err != nil { + return builtCheckpointEvidence{}, err + } + candidateAbs, err := filepath.Abs(options.CandidateDir) + if err != nil { + return builtCheckpointEvidence{}, err + } + if err := validateCheckpointPathComponents(rootAbs, candidateAbs); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("final candidate directory: %w", err) + } + relativeCandidate, err := filepath.Rel(rootAbs, candidateAbs) + if err != nil || filepath.ToSlash(relativeCandidate) != "final/candidate" { + return builtCheckpointEvidence{}, errors.New("final candidate directory must be the canonical final/candidate path under artifact-root") + } + replay := mpcceremony.ReplayPaths{ + TranscriptRoot: options.ArtifactRoot, + CoordinatorPublicKeyHex: trusted.Definition.Coordinator.Ed25519PublicKeyHex, + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + Phase1ChainPath: options.ChainPath, + Phase1ChainSignaturePath: options.ChainSignaturePath, + Phase1ClosePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Closure.Record.Name)), + Phase1CloseSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Closure.Signature.Name)), + Phase1BeaconPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Beacon.Record.Name)), + Phase1BeaconSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Beacon.Signature.Name)), + Phase1SealPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Seal.Record.Name)), + Phase1SealSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Seal.Signature.Name)), + Phase2ChainPath: options.Phase2ChainPath, + Phase2ChainSignaturePath: options.Phase2ChainSignaturePath, + Phase2ClosePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase2Closure.Record.Name)), + Phase2CloseSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase2Closure.Signature.Name)), + Phase2BeaconPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase2Beacon.Record.Name)), + Phase2BeaconSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase2Beacon.Signature.Name)), + } + _, candidateRefs, err := mpcceremony.VerifyFinalCandidateCheckpoint(replay, circuit, options.CandidateDir) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("final candidate verification: %w", err) + } + prefixed := make([]mpcceremony.ArtifactRef, 0, len(candidateRefs)) + for _, ref := range candidateRefs { + ref.Name = "final/candidate/" + ref.Name + prefixed = append(prefixed, ref) + } + prefixed = checkpointSortedArtifacts(prefixed...) + var recordRefs mpcceremony.SignedArtifactRefs + evidence := make([]mpcceremony.ArtifactRef, 0, len(prefixed)-2) + for _, ref := range prefixed { + switch ref.Name { + case "final/candidate/" + mpcceremony.CandidateMetadataFile: + recordRefs.Record = ref + case "final/candidate/" + mpcceremony.CandidateSignatureFile: + recordRefs.Signature = ref + default: + evidence = append(evidence, ref) + } + } + if err := recordRefs.Validate(); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("final candidate record: %w", err) + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{Kind: mpcceremony.CheckpointFinalCandidateRecorded, Record: &recordRefs, Evidence: evidence} + checkpoint.Phase1 = phase1State + state := phase2State + checkpoint.Phase2 = &state + checkpoint.FinalCandidate = &recordRefs + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), prefixed...)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func buildFinalReleaseCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phase1State, phase2State mpcceremony.CheckpointPhaseState) (builtCheckpointEvidence, error) { + if previous.FinalCandidate == nil || previous.Phase2Beacon == nil { + return builtCheckpointEvidence{}, errors.New("final release requires the authenticated final candidate checkpoint") + } + if previous.FinalRelease != nil { + return builtCheckpointEvidence{}, errors.New("final release is already recorded in the previous checkpoint") + } + rootAbs, err := filepath.Abs(options.ArtifactRoot) + if err != nil { + return builtCheckpointEvidence{}, err + } + releaseAbs, err := filepath.Abs(options.ReleaseDir) + if err != nil { + return builtCheckpointEvidence{}, err + } + if err := validateCheckpointPathComponents(rootAbs, releaseAbs); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("final release directory: %w", err) + } + relativeRelease, err := filepath.Rel(rootAbs, releaseAbs) + if err != nil || filepath.ToSlash(relativeRelease) != "final/release" { + return builtCheckpointEvidence{}, errors.New("final release directory must be the canonical final/release path under artifact-root") + } + verified, releaseRefs, err := mpcceremony.VerifyFinalReleaseCheckpoint(mpcceremony.VerifyReleaseOptions{ + DefinitionPath: options.CeremonyPath, DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyHex: trusted.Definition.Coordinator.Ed25519PublicKeyHex, + KeysDir: options.ReleaseDir, TrustedPublicKeyHex: trusted.Definition.ReleaseSigner.Ed25519PublicKeyHex, + ExpectedSignatureKeyID: trusted.Definition.ReleaseSigner.KeyID, RequireProvingKey: true, + }) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("final release verification: %w", err) + } + prefixed := make([]mpcceremony.ArtifactRef, 0, len(releaseRefs)) + for _, ref := range releaseRefs { + ref.Name = "final/release/" + ref.Name + prefixed = append(prefixed, ref) + } + prefixed = checkpointSortedArtifacts(prefixed...) + releaseCandidate := make(map[string]mpcceremony.ArtifactRef) + for _, ref := range prefixed { + suffix, ok := strings.CutPrefix(ref.Name, "final/release/") + if ok { + releaseCandidate[suffix] = ref + } + } + for _, frozen := range previous.AcceptedArtifacts { + suffix, ok := strings.CutPrefix(frozen.Name, "final/candidate/") + if !ok { + continue + } + copied, exists := releaseCandidate[suffix] + if !exists || copied.Digest != frozen.Digest { + return builtCheckpointEvidence{}, fmt.Errorf("final release candidate file %q differs from the checkpointed final candidate", suffix) + } + } + var releaseRecord mpcceremony.SignedArtifactRefs + evidence := make([]mpcceremony.ArtifactRef, 0, len(prefixed)-2) + for _, ref := range prefixed { + switch ref.Name { + case "final/release/" + keybundle.ManifestFile: + releaseRecord.Record = ref + case "final/release/" + keybundle.ManifestSignatureFile: + releaseRecord.Signature = ref + default: + evidence = append(evidence, ref) + } + } + if err := releaseRecord.Validate(); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("final release manifest: %w", err) + } + if releaseRecord.Record.Digest.SHA256 != verified.ManifestSHA256 { + return builtCheckpointEvidence{}, errors.New("verified release manifest changed during checkpoint preparation") + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{Kind: mpcceremony.CheckpointFinalReleaseRecorded, Record: &releaseRecord, Evidence: evidence} + checkpoint.Phase1 = phase1State + state := phase2State + checkpoint.Phase2 = &state + checkpoint.FinalRelease = &releaseRecord + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), prefixed...)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func buildPhase1BeaconCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState) (builtCheckpointEvidence, error) { + if previous.Phase1Closure == nil { + return builtCheckpointEvidence{}, errors.New("phase1 beacon requires a closure in the previous checkpoint") + } + if previous.Phase1Beacon != nil { + return builtCheckpointEvidence{}, errors.New("phase1 beacon is already recorded in the previous checkpoint") + } + publicKey, err := keybundle.DecodePublicKeyHex(trusted.Definition.Coordinator.Ed25519PublicKeyHex) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("coordinator public key: %w", err) + } + closureBytes, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Closure.Record, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure: %w", err) + } + closureSignature, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Closure.Signature, 4096) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure signature: %w", err) + } + var closure mpcceremony.CloseRecord + if err := mpcceremony.VerifySignedRecord(closureBytes, closureSignature, &closure, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure signature: %w", err) + } + + beaconBytes, beaconSignature, beaconRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon: %w", err) + } + if err := requireCheckpointArtifactName(beaconRefs.Record, "phase1/beacon/record.json", "phase1 beacon"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(beaconRefs.Signature, "phase1/beacon/record.sig", "phase1 beacon signature"); err != nil { + return builtCheckpointEvidence{}, err + } + var beacon mpcceremony.BeaconRecord + if err := mpcceremony.VerifySignedRecord(beaconBytes, beaconSignature, &beacon, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon signature: %w", err) + } + if beacon.Phase != mpcceremony.Phase1 { + return builtCheckpointEvidence{}, errors.New("phase1-beacon-recorded checkpoint received a non-phase1 beacon") + } + if err := mpcceremony.ValidateBeacon(trusted.Definition, closure, beacon); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon: %w", err) + } + if err := mpcceremony.VerifyBeaconRecordFiles(trusted, options.ArtifactRoot, closure, beacon); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon evidence: %w", err) + } + rawResponse, err := checkpointArtifactRef(options.ArtifactRoot, filepath.Join(options.ArtifactRoot, filepath.FromSlash(beacon.RawResponse.Name))) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 raw beacon response: %w", err) + } + if rawResponse != beacon.RawResponse { + return builtCheckpointEvidence{}, errors.New("phase1 raw beacon response changed during validation") + } + if err := requireCheckpointArtifactName(rawResponse, "phase1/beacon/raw-response.bin", "phase1 raw beacon response"); err != nil { + return builtCheckpointEvidence{}, err + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{ + Kind: mpcceremony.CheckpointPhase1BeaconRecorded, Phase: mpcceremony.Phase1, + Record: &beaconRefs, Evidence: []mpcceremony.ArtifactRef{rawResponse}, + } + checkpoint.Phase1 = phaseState + checkpoint.Phase1Beacon = &beaconRefs + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), beaconRefs.Record, beaconRefs.Signature, rawResponse)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func buildPhase1SealCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState, circuit *mpcceremony.CompiledCircuit) (builtCheckpointEvidence, error) { + if previous.Phase1Closure == nil || previous.Phase1Beacon == nil { + return builtCheckpointEvidence{}, errors.New("phase1 seal requires closure and beacon in the previous checkpoint") + } + if previous.Phase1Seal != nil { + return builtCheckpointEvidence{}, errors.New("phase1 is already sealed in the previous checkpoint") + } + closureBytes, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Closure.Record, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure: %w", err) + } + closureSignature, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Closure.Signature, 4096) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure signature: %w", err) + } + beaconBytes, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Beacon.Record, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon: %w", err) + } + beaconSignature, err := checkpointBytesForRef(options.ArtifactRoot, previous.Phase1Beacon.Signature, 4096) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon signature: %w", err) + } + sealBytes, sealSignature, sealRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 seal: %w", err) + } + if err := requireCheckpointArtifactName(sealRefs.Record, "phase1/sealed/seal.json", "phase1 seal"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(sealRefs.Signature, "phase1/sealed/seal.sig", "phase1 seal signature"); err != nil { + return builtCheckpointEvidence{}, err + } + publicKey, err := keybundle.DecodePublicKeyHex(trusted.Definition.Coordinator.Ed25519PublicKeyHex) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("coordinator public key: %w", err) + } + var seal mpcceremony.SealRecord + if err := mpcceremony.VerifySignedRecord(sealBytes, sealSignature, &seal, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 seal signature: %w", err) + } + var closure mpcceremony.CloseRecord + if err := mpcceremony.VerifySignedRecord(closureBytes, closureSignature, &closure, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure signature: %w", err) + } + var beacon mpcceremony.BeaconRecord + if err := mpcceremony.VerifySignedRecord(beaconBytes, beaconSignature, &beacon, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 beacon signature: %w", err) + } + if seal.Phase != mpcceremony.Phase1 { + return builtCheckpointEvidence{}, errors.New("phase1-sealed checkpoint received a non-phase1 seal") + } + verified, err := mpcceremony.VerifyPhase1SealFiles(mpcceremony.VerifyPhase1SealFilesOptions{ + Trust: trustPaths(options.CeremonyPath, options.CeremonySignaturePath, options.CoordinatorPublicKeyFile), + Circuit: circuit, TranscriptRoot: options.ArtifactRoot, + Phase1ChainPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1.Chain.Record.Name)), + Phase1ChainSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1.Chain.Signature.Name)), + Phase1ClosePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Closure.Record.Name)), + Phase1CloseSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Closure.Signature.Name)), + Phase1BeaconPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Beacon.Record.Name)), + Phase1BeaconSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Phase1Beacon.Signature.Name)), + Phase1SealPath: options.TransitionRecordPath, Phase1SealSignaturePath: options.TransitionRecordSignaturePath, + }) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("full phase1 seal verification: %w", err) + } + if verified.Seal.SealID != seal.SealID { + return builtCheckpointEvidence{}, errors.New("verified phase1 seal differs from transition record") + } + if verified.Close.CloseID != closure.CloseID || seal.BeaconID != beacon.BeaconID { + return builtCheckpointEvidence{}, errors.New("phase1 seal does not bind the checkpoint's exact closure and beacon") + } + commons := verified.Commons + if err := requireCheckpointArtifactName(commons, "phase1/sealed/commons.bin", "phase1 commons"); err != nil { + return builtCheckpointEvidence{}, err + } + actualCommons, err := checkpointArtifactRef(options.ArtifactRoot, filepath.Join(options.ArtifactRoot, filepath.FromSlash(commons.Name))) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 commons: %w", err) + } + if actualCommons != commons { + return builtCheckpointEvidence{}, errors.New("phase1 commons changed during validation") + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{ + Kind: mpcceremony.CheckpointPhase1Sealed, Phase: mpcceremony.Phase1, + Record: &sealRefs, Evidence: []mpcceremony.ArtifactRef{commons}, + } + checkpoint.Phase1 = phaseState + checkpoint.Phase1Seal = &sealRefs + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), sealRefs.Record, sealRefs.Signature, commons)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func buildPhase1ClosedCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState, chain mpcceremony.Chain) (builtCheckpointEvidence, error) { + if previous.Phase1Closure != nil { + return builtCheckpointEvidence{}, errors.New("phase1 is already closed in the previous checkpoint") + } + closeBytes, closeSignature, closeRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure: %w", err) + } + if err := requireCheckpointArtifactName(closeRefs.Record, "phase1/closure/record.json", "phase1 closure"); err != nil { + return builtCheckpointEvidence{}, err + } + if err := requireCheckpointArtifactName(closeRefs.Signature, "phase1/closure/record.sig", "phase1 closure signature"); err != nil { + return builtCheckpointEvidence{}, err + } + publicKey, err := keybundle.DecodePublicKeyHex(trusted.Definition.Coordinator.Ed25519PublicKeyHex) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("coordinator public key: %w", err) + } + var closeRecord mpcceremony.CloseRecord + if err := mpcceremony.VerifySignedRecord(closeBytes, closeSignature, &closeRecord, trusted.Definition.Coordinator.KeyID, publicKey); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure signature: %w", err) + } + if closeRecord.Phase != mpcceremony.Phase1 { + return builtCheckpointEvidence{}, errors.New("phase1-closed checkpoint received a non-phase1 closure") + } + if err := mpcceremony.ValidateClose(trusted.Definition, chain, closeRecord); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("phase1 closure: %w", err) + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = mpcceremony.CheckpointTransition{ + Kind: mpcceremony.CheckpointPhase1Closed, Phase: mpcceremony.Phase1, Record: &closeRefs, + } + checkpoint.Phase1 = phaseState + checkpoint.Phase1Closure = &closeRefs + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), closeRefs.Record, closeRefs.Signature)...) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func checkpointSchemaForDefinition(definition mpcceremony.CeremonyDefinition) string { + if definition.Schema == mpcceremony.DefinitionSchema { + return mpcceremony.CheckpointSchema + } + return mpcceremony.CheckpointSchemaV1 +} + +func buildOutboundCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState, phase mpcceremony.Phase) (builtCheckpointEvidence, error) { + recordBytes, record, recordRefs, err := loadSignedOperationalPair(options, mpcceremony.RecordHandoff, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, err + } + _ = recordBytes + handoff := record.(*mpcceremony.TransferHandoff) + policy := trusted.Definition.Phase1Policy + transitionKind := mpcceremony.CheckpointPhase1OutboundPublished + if phase == mpcceremony.Phase2 { + policy = trusted.Definition.Phase2Policy + transitionKind = mpcceremony.CheckpointPhase2OutboundPublished + } + index, participantID, err := nextCheckpointParticipant(phaseState, policy, phase) + if err != nil { + return builtCheckpointEvidence{}, err + } + participant, _ := trusted.Definition.ParticipantByID(participantID) + if handoff.Phase != phase || handoff.Index != index || handoff.PredecessorHeadID != phaseState.HeadRecordID || + handoff.SenderID != trusted.Definition.Coordinator.ID || handoff.SenderKeyID != trusted.Definition.Coordinator.KeyID || + handoff.RecipientID != participantID || handoff.RecipientKeyID != participant.Identity.KeyID || + !slices.Equal(handoff.Files, []mpcceremony.ArtifactRef{phaseState.HeadPayload}) { + return builtCheckpointEvidence{}, errors.New("outbound handoff does not bind the exact current head, next participant, and input payload") + } + transition := mpcceremony.CheckpointTransition{ + Kind: transitionKind, Phase: phase, Index: index, + ParticipantID: participantID, AttemptID: options.AttemptID, Record: &recordRefs, + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = transition + if phase == mpcceremony.Phase2 { + state := phaseState + checkpoint.Phase2 = &state + } else { + checkpoint.Phase1 = phaseState + } + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), recordRefs.Record, recordRefs.Signature)...) + checkpoint.Submissions = append(append([]mpcceremony.CheckpointSubmissionSlot(nil), previous.Submissions...), mpcceremony.CheckpointSubmissionSlot{ + Kind: mpcceremony.CheckpointSubmissionReceipt, Phase: phase, Index: index, + IdentityID: participantID, AttemptID: options.AttemptID, ManifestKey: options.ManifestKey, + BasisCheckpointSHA256: previousRefs.Record.Digest.SHA256, ParentHeadID: phaseState.HeadRecordID, + Status: mpcceremony.CheckpointSubmissionAllocated, + }) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func nextCheckpointParticipant(state mpcceremony.CheckpointPhaseState, policy mpcceremony.PhasePolicy, phase mpcceremony.Phase) (uint8, string, error) { + if int(state.AcceptedCount) >= len(policy.Participants) { + return 0, "", fmt.Errorf("no next %s participant in the authenticated schedule", phase) + } + return state.AcceptedCount + 1, policy.Participants[int(state.AcceptedCount)], nil +} + +func buildReceiptCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState, phase mpcceremony.Phase) (builtCheckpointEvidence, error) { + envelopeBytes, envelopeSignatureBytes, envelopeRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("receipt submission envelope: %w", err) + } + var untrustedEnvelope mpcceremony.SubmissionEnvelopeV1 + if err := mpcceremony.UnmarshalCanonical(envelopeBytes, &untrustedEnvelope); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("receipt submission envelope: %w", err) + } + slot, err := findAllocatedSubmission(previous, untrustedEnvelope) + if err != nil { + return builtCheckpointEvidence{}, err + } + envelope, err := mpcceremony.VerifySignedSubmissionEnvelope(trusted.Definition, previous, slot, envelopeBytes, envelopeSignatureBytes) + if err != nil { + return builtCheckpointEvidence{}, err + } + if envelope.Kind != mpcceremony.CheckpointSubmissionReceipt { + return builtCheckpointEvidence{}, errors.New("receipt-accepted checkpoint requires a receipt submission envelope") + } + if err := verifyReceiptEnvelopePayloads(options.ArtifactRoot, trusted, previous, envelope); err != nil { + return builtCheckpointEvidence{}, err + } + manifestBytes, manifest, err := checkpointArtifactBytes(options.ArtifactRoot, options.ManifestPath, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("submission manifest: %w", err) + } + ackBytes, ackSignatureBytes, ackRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.AcknowledgementPath, options.AcknowledgementSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("submission acknowledgement: %w", err) + } + ack, err := mpcceremony.VerifySignedSubmissionAcknowledgement( + trusted.Definition, previous, slot, + envelopeRefs.Record.Name, envelopeRefs.Signature.Name, envelopeBytes, envelopeSignatureBytes, + manifest.Name, manifestBytes, ackBytes, ackSignatureBytes, + ) + if err != nil { + return builtCheckpointEvidence{}, err + } + if ack.Result != mpcceremony.SubmissionAccepted { + return builtCheckpointEvidence{}, errors.New("receipt-accepted checkpoint requires an accepted acknowledgement") + } + transitionKind := mpcceremony.CheckpointPhase1ReceiptAccepted + if phase == mpcceremony.Phase2 { + transitionKind = mpcceremony.CheckpointPhase2ReceiptAccepted + } + evidence := checkpointSortedArtifacts(append([]mpcceremony.ArtifactRef{manifest}, envelope.Payloads...)...) + transition := mpcceremony.CheckpointTransition{ + Kind: transitionKind, Phase: phase, + Index: slot.Index, ParticipantID: slot.IdentityID, AttemptID: slot.AttemptID, + NextAttemptID: options.NextAttemptID, Record: &envelopeRefs, Acknowledgement: &ackRefs, Evidence: evidence, + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = transition + if phase == mpcceremony.Phase2 { + state := phaseState + checkpoint.Phase2 = &state + } else { + checkpoint.Phase1 = phaseState + } + accepted := append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), envelopeRefs.Record, envelopeRefs.Signature, ackRefs.Record, ackRefs.Signature) + accepted = append(accepted, evidence...) + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(accepted...) + checkpoint.Submissions = append([]mpcceremony.CheckpointSubmissionSlot(nil), previous.Submissions...) + for index := range checkpoint.Submissions { + if checkpoint.Submissions[index] == slot { + checkpoint.Submissions[index].Status = mpcceremony.CheckpointSubmissionAccepted + checkpoint.Submissions[index].Acknowledgement = &ackRefs + } + } + checkpoint.Submissions = append(checkpoint.Submissions, mpcceremony.CheckpointSubmissionSlot{ + Kind: mpcceremony.CheckpointSubmissionCandidate, Phase: phase, Index: slot.Index, + IdentityID: slot.IdentityID, AttemptID: options.NextAttemptID, ManifestKey: options.NextManifestKey, + BasisCheckpointSHA256: previousRefs.Record.Digest.SHA256, ParentHeadID: phaseState.HeadRecordID, + Status: mpcceremony.CheckpointSubmissionAllocated, + }) + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func buildCandidateCheckpoint(options CheckpointEvidenceOptions, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, previousRefs mpcceremony.SignedArtifactRefs, phaseState mpcceremony.CheckpointPhaseState, chain mpcceremony.Chain, phase mpcceremony.Phase) (builtCheckpointEvidence, error) { + previousState := previous.Phase1 + transitionKind := mpcceremony.CheckpointPhase1CandidateAccepted + if phase == mpcceremony.Phase2 { + if previous.Phase2 == nil { + return builtCheckpointEvidence{}, errors.New("phase2 candidate requires initialized phase2 state") + } + previousState = *previous.Phase2 + transitionKind = mpcceremony.CheckpointPhase2CandidateAccepted + } + if phaseState.AcceptedCount != previousState.AcceptedCount+1 || len(chain.Records) == 0 { + return builtCheckpointEvidence{}, errors.New("candidate checkpoint chain must advance the previous head by exactly one record") + } + envelopeBytes, envelopeSignatureBytes, envelopeRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.TransitionRecordPath, options.TransitionRecordSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("candidate submission envelope: %w", err) + } + var untrustedEnvelope mpcceremony.SubmissionEnvelopeV1 + if err := mpcceremony.UnmarshalCanonical(envelopeBytes, &untrustedEnvelope); err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("candidate submission envelope: %w", err) + } + slot, err := findAllocatedSubmission(previous, untrustedEnvelope) + if err != nil { + return builtCheckpointEvidence{}, err + } + if slot.Kind != mpcceremony.CheckpointSubmissionCandidate { + return builtCheckpointEvidence{}, errors.New("candidate-accepted checkpoint requires a candidate submission slot") + } + envelope, err := mpcceremony.VerifySignedSubmissionEnvelope(trusted.Definition, previous, slot, envelopeBytes, envelopeSignatureBytes) + if err != nil { + return builtCheckpointEvidence{}, err + } + acceptedRecord := chain.Records[len(chain.Records)-1] + if acceptedRecord.Index != slot.Index || acceptedRecord.ParticipantID != slot.IdentityID || + acceptedRecord.PreviousRecordID != previousState.HeadRecordID { + return builtCheckpointEvidence{}, errors.New("authenticated accepted chain record does not match the allocated candidate slot and previous head") + } + if err := verifyCandidateEnvelopePayloads(options.ArtifactRoot, envelope, acceptedRecord); err != nil { + return builtCheckpointEvidence{}, err + } + manifestBytes, manifest, err := checkpointArtifactBytes(options.ArtifactRoot, options.ManifestPath, maxOperationalRecordBytes) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("candidate manifest: %w", err) + } + ackBytes, ackSignatureBytes, ackRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.AcknowledgementPath, options.AcknowledgementSignaturePath) + if err != nil { + return builtCheckpointEvidence{}, fmt.Errorf("candidate acknowledgement: %w", err) + } + ack, err := mpcceremony.VerifySignedSubmissionAcknowledgement( + trusted.Definition, previous, slot, + envelopeRefs.Record.Name, envelopeRefs.Signature.Name, envelopeBytes, envelopeSignatureBytes, + manifest.Name, manifestBytes, ackBytes, ackSignatureBytes, + ) + if err != nil { + return builtCheckpointEvidence{}, err + } + if ack.Result != mpcceremony.SubmissionAccepted { + return builtCheckpointEvidence{}, errors.New("candidate-accepted checkpoint requires an accepted acknowledgement") + } + evidence := checkpointSortedArtifacts(append([]mpcceremony.ArtifactRef{manifest}, envelope.Payloads...)...) + transition := mpcceremony.CheckpointTransition{ + Kind: transitionKind, Phase: phase, + Index: slot.Index, ParticipantID: slot.IdentityID, AttemptID: slot.AttemptID, + Record: &envelopeRefs, Acknowledgement: &ackRefs, Evidence: evidence, + } + checkpoint := previous + checkpoint.Sequence++ + checkpoint.PreviousCheckpoint = &previousRefs + checkpoint.Transition = transition + if phase == mpcceremony.Phase2 { + state := phaseState + checkpoint.Phase2 = &state + } else { + checkpoint.Phase1 = phaseState + } + accepted := append(append([]mpcceremony.ArtifactRef(nil), previous.AcceptedArtifacts...), + envelopeRefs.Record, envelopeRefs.Signature, ackRefs.Record, ackRefs.Signature, + phaseState.Chain.Record, phaseState.Chain.Signature) + accepted = append(accepted, evidence...) + checkpoint.AcceptedArtifacts = checkpointSortedArtifacts(accepted...) + checkpoint.Submissions = append([]mpcceremony.CheckpointSubmissionSlot(nil), previous.Submissions...) + for index := range checkpoint.Submissions { + if checkpoint.Submissions[index] == slot { + checkpoint.Submissions[index].Status = mpcceremony.CheckpointSubmissionAccepted + checkpoint.Submissions[index].Acknowledgement = &ackRefs + } + } + return finishTransitionCheckpoint(trusted, previous, checkpoint) +} + +func verifyCandidateEnvelopePayloads(root string, envelope mpcceremony.SubmissionEnvelopeV1, accepted mpcceremony.ChainRecord) error { + if len(envelope.Payloads) != 5 { + return errors.New("candidate submission must contain exactly contribution, attestation, attestation signature, cleanup record, and cleanup signature") + } + // Contributions are intentionally much larger than ordinary operational + // records in production. Authenticate that exact file with streaming hashes; + // VerifyAcceptedPhase1Chain has already enforced its shape-derived exact + // length and canonical native encoding without an unbounded allocation. + if err := checkCustodyFile(root, accepted.OutputPayload); err != nil { + return fmt.Errorf("candidate contribution payload %q: %w", accepted.OutputPayload.Name, err) + } + for _, item := range []struct { + ref mpcceremony.ArtifactRef + limit int64 + }{ + {accepted.Attestation, maxOperationalRecordBytes}, + {accepted.AttestationSignature, 4096}, + {accepted.Erasure, maxOperationalRecordBytes}, + {accepted.ErasureSignature, 4096}, + } { + if _, err := checkpointBytesForRef(root, item.ref, item.limit); err != nil { + return fmt.Errorf("candidate submission payload %q: %w", item.ref.Name, err) + } + } + want := []mpcceremony.ArtifactRef{ + accepted.OutputPayload, accepted.Attestation, accepted.AttestationSignature, + accepted.Erasure, accepted.ErasureSignature, + } + got := append([]mpcceremony.ArtifactRef(nil), envelope.Payloads...) + sortRefs := func(values []mpcceremony.ArtifactRef) { + slices.SortFunc(values, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + } + sortRefs(want) + sortRefs(got) + if !slices.Equal(want, got) { + return errors.New("candidate submission payloads do not exactly match the fully verified accepted chain record") + } + return nil +} + +func finishTransitionCheckpoint(trusted *mpcceremony.TrustedCeremony, previous, checkpoint mpcceremony.Checkpoint) (builtCheckpointEvidence, error) { + if err := mpcceremony.ValidateCheckpointTransition(previous, checkpoint); err != nil { + return builtCheckpointEvidence{}, err + } + return finishBuiltCheckpoint(trusted, checkpoint) +} + +func finishBuiltCheckpoint(trusted *mpcceremony.TrustedCeremony, checkpoint mpcceremony.Checkpoint) (builtCheckpointEvidence, error) { + if err := checkpoint.Validate(); err != nil { + return builtCheckpointEvidence{}, err + } + canonical, err := mpcceremony.MarshalCanonical(checkpoint) + if err != nil { + return builtCheckpointEvidence{}, err + } + request, err := mpcceremony.NewCheckpointSigningRequest(trusted.Definition, canonical) + if err != nil { + return builtCheckpointEvidence{}, err + } + return builtCheckpointEvidence{trusted: trusted, checkpoint: checkpoint, canonical: canonical, request: request}, nil +} + +func loadSignedOperationalPair(options CheckpointEvidenceOptions, kind mpcceremony.OperationalRecordType, recordPath, signaturePath string) ([]byte, any, mpcceremony.SignedArtifactRefs, error) { + canonical, record, trusted, err := loadBoundOperationalRecord(kind, recordPath, options.CeremonyPath, options.CeremonySignaturePath, options.CoordinatorPublicKeyFile) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + owner, err := mpcceremony.VerifyOperationalRecordBinding(trusted.Definition, definitionBytes, record) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + publicKey, err := hex.DecodeString(owner.Ed25519PublicKeyHex) + if err != nil || len(publicKey) != ed25519.PublicKeySize { + return nil, nil, mpcceremony.SignedArtifactRefs{}, errors.New("operational signer has invalid public key") + } + signatureBytes, err := readRegularOperationalFile(signaturePath, 4096) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + var signature mpcceremony.DetachedSignature + if err := mpcceremony.UnmarshalCanonical(signatureBytes, &signature); err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + if err := mpcceremony.VerifyExact(canonical, signature, owner.KeyID, ed25519.PublicKey(publicKey)); err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + refs, err := checkpointPairRefs(options.ArtifactRoot, recordPath, signaturePath) + if err == nil && (refs.Record.Digest != mpcceremony.NewDigest(canonical) || refs.Signature.Digest != mpcceremony.NewDigest(signatureBytes)) { + return nil, nil, mpcceremony.SignedArtifactRefs{}, errors.New("signed operational record changed during validation") + } + return canonical, record, refs, err +} + +func verifyReceiptEnvelopePayloads(root string, trusted *mpcceremony.TrustedCeremony, previous mpcceremony.Checkpoint, envelope mpcceremony.SubmissionEnvelopeV1) error { + if len(envelope.Payloads) != 2 { + return errors.New("receipt submission must contain exactly the receipt record and its signature") + } + var receiptBytes []byte + var receipt *mpcceremony.TransferReceipt + var receiptRef mpcceremony.ArtifactRef + for _, ref := range envelope.Payloads { + bytes, err := checkpointBytesForRef(root, ref, maxOperationalRecordBytes) + if err != nil { + return fmt.Errorf("submission payload %q: %w", ref.Name, err) + } + parsed, err := mpcceremony.ParseOperationalRecord(mpcceremony.RecordReceipt, bytes) + if err == nil { + if receipt != nil { + return errors.New("receipt submission contains multiple receipt records") + } + receiptBytes, receiptRef = bytes, ref + receipt = parsed.(*mpcceremony.TransferReceipt) + } + } + if receipt == nil { + return errors.New("receipt submission does not contain a canonical transfer receipt") + } + participant, ok := trusted.Definition.ParticipantByID(envelope.SubmitterID) + if !ok { + return errors.New("receipt submitter is not an assigned participant") + } + publicKeyBytes, _ := hex.DecodeString(participant.Identity.Ed25519PublicKeyHex) + foundSignature := false + for _, ref := range envelope.Payloads { + if ref == receiptRef { + continue + } + signatureBytes, err := checkpointBytesForRef(root, ref, 4096) + if err != nil { + return err + } + var signature mpcceremony.DetachedSignature + if err := mpcceremony.UnmarshalCanonical(signatureBytes, &signature); err == nil && + mpcceremony.VerifyExact(receiptBytes, signature, participant.Identity.KeyID, ed25519.PublicKey(publicKeyBytes)) == nil { + foundSignature = true + } + } + if !foundSignature { + return errors.New("receipt submission does not contain the participant's exact receipt signature") + } + outboundRefs := previous.Transition.Record + if outboundRefs == nil { + return errors.New("previous checkpoint does not contain the outbound handoff") + } + outboundBytes, err := checkpointBytesForRef(root, outboundRefs.Record, maxOperationalRecordBytes) + if err != nil { + return err + } + outboundSignatureBytes, err := checkpointBytesForRef(root, outboundRefs.Signature, 4096) + if err != nil { + return err + } + var outbound mpcceremony.TransferHandoff + if err := mpcceremony.UnmarshalCanonical(outboundBytes, &outbound); err != nil { + return err + } + var outboundSignature mpcceremony.DetachedSignature + if err := mpcceremony.UnmarshalCanonical(outboundSignatureBytes, &outboundSignature); err != nil { + return err + } + if err := mpcceremony.VerifyExact(outboundBytes, outboundSignature, trusted.Definition.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { + return err + } + if err := mpcceremony.VerifyTransferReceipt(outboundBytes, outbound, *receipt); err != nil { + return err + } + return nil +} + +func findAllocatedSubmission(checkpoint mpcceremony.Checkpoint, envelope mpcceremony.SubmissionEnvelopeV1) (mpcceremony.CheckpointSubmissionSlot, error) { + for _, slot := range checkpoint.Submissions { + if slot.Kind == envelope.Kind && slot.Phase == envelope.Phase && slot.Index == envelope.Index && + slot.IdentityID == envelope.SubmitterID && slot.AttemptID == envelope.AttemptID && slot.Status == mpcceremony.CheckpointSubmissionAllocated { + return slot, nil + } + } + return mpcceremony.CheckpointSubmissionSlot{}, errors.New("submission envelope does not match an allocated checkpoint slot") +} + +func checkpointSignedBytes(root, recordPath, signaturePath string) ([]byte, []byte, mpcceremony.SignedArtifactRefs, error) { + recordBytes, recordRef, err := checkpointArtifactBytes(root, recordPath, maxOperationalRecordBytes) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + signatureBytes, signatureRef, err := checkpointArtifactBytes(root, signaturePath, 4096) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + refs := mpcceremony.SignedArtifactRefs{Record: recordRef, Signature: signatureRef} + return recordBytes, signatureBytes, refs, nil +} + +func requireCheckpointArtifactName(ref mpcceremony.ArtifactRef, expected, label string) error { + if ref.Name != expected { + return fmt.Errorf("%s must use canonical storage path %q, got %q", label, expected, ref.Name) + } + return nil +} + +// checkpointArtifactBytes binds validation bytes and checkpoint references to +// the same opened file. Callers must not separately reopen a mutable path for +// semantic verification after committing the returned reference. +func checkpointArtifactBytes(root, path string, limit int64) ([]byte, mpcceremony.ArtifactRef, error) { + rootAbs, err := filepath.Abs(root) + if err != nil { + return nil, mpcceremony.ArtifactRef{}, err + } + pathAbs, err := filepath.Abs(path) + if err != nil { + return nil, mpcceremony.ArtifactRef{}, err + } + rel, err := filepath.Rel(rootAbs, pathAbs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { + return nil, mpcceremony.ArtifactRef{}, errors.New("artifact path escapes artifact root") + } + data, err := openCheckpointArtifactBytes(rootAbs, filepath.Clean(rel), limit) + if err != nil { + return nil, mpcceremony.ArtifactRef{}, err + } + ref := mpcceremony.ArtifactRef{Name: filepath.ToSlash(rel), Digest: mpcceremony.NewDigest(data)} + if err := ref.Validate(); err != nil { + return nil, mpcceremony.ArtifactRef{}, err + } + return data, ref, nil +} + +func checkpointPairRefs(root, recordPath, signaturePath string) (mpcceremony.SignedArtifactRefs, error) { + record, err := checkpointArtifactRef(root, recordPath) + if err != nil { + return mpcceremony.SignedArtifactRefs{}, err + } + signature, err := checkpointArtifactRef(root, signaturePath) + if err != nil { + return mpcceremony.SignedArtifactRefs{}, err + } + refs := mpcceremony.SignedArtifactRefs{Record: record, Signature: signature} + return refs, refs.Validate() +} + +func checkpointArtifactRef(root, path string) (mpcceremony.ArtifactRef, error) { + rootAbs, err := filepath.Abs(root) + if err != nil { + return mpcceremony.ArtifactRef{}, err + } + pathAbs, err := filepath.Abs(path) + if err != nil { + return mpcceremony.ArtifactRef{}, err + } + rel, err := filepath.Rel(rootAbs, pathAbs) + if err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(os.PathSeparator)) || filepath.IsAbs(rel) { + return mpcceremony.ArtifactRef{}, errors.New("artifact path escapes artifact root") + } + name := filepath.ToSlash(rel) + if err := validateCheckpointPathComponents(rootAbs, pathAbs); err != nil { + return mpcceremony.ArtifactRef{}, err + } + digest, err := custodyDigest(pathAbs) + if err != nil { + return mpcceremony.ArtifactRef{}, err + } + ref := mpcceremony.ArtifactRef{Name: name, Digest: digest} + if err := checkCustodyFile(rootAbs, ref); err != nil { + return mpcceremony.ArtifactRef{}, err + } + return ref, nil +} + +func validateCheckpointPathComponents(root, path string) error { + for current := path; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("checkpoint artifacts cannot traverse symbolic links") + } + if current == root { + return nil + } + if filepath.Dir(current) == current { + return errors.New("checkpoint artifact path does not reach artifact root") + } + } +} + +func checkpointBytesForRef(root string, ref mpcceremony.ArtifactRef, limit int64) ([]byte, error) { + if err := ref.Validate(); err != nil { + return nil, err + } + data, actual, err := checkpointArtifactBytes(root, filepath.Join(root, filepath.FromSlash(ref.Name)), limit) + if err != nil { + return nil, err + } + if actual != ref { + return nil, fmt.Errorf("retained file %q differs from checkpoint digest", ref.Name) + } + return data, nil +} + +func checkpointSortedArtifacts(values ...mpcceremony.ArtifactRef) []mpcceremony.ArtifactRef { + result := append([]mpcceremony.ArtifactRef(nil), values...) + slices.SortFunc(result, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + return result +} diff --git a/cmd/mpc-ceremony/checkpoint_command_test.go b/cmd/mpc-ceremony/checkpoint_command_test.go new file mode 100644 index 00000000..3d90eba0 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_command_test.go @@ -0,0 +1,1339 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "proof-tool/internal/mpcceremony" +) + +func TestCheckpointPrepareSignAndFullyVerifyInitial(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + packet := filepath.Join(fixture.root, "prepared", "cp0") + prepare := append(checkpointInitialEvidenceArgs(fixture), "--out-dir", packet) + result := runCheckpointCommandCLI(t, append([]string{"--format", "json", "checkpoint", "prepare"}, prepare...)) + checkpointPath := result.Outputs["checkpoint"] + requestPath := result.Outputs["signing_request"] + if checkpointPath == "" || requestPath == "" { + t.Fatalf("prepare outputs = %#v", result.Outputs) + } + + keyPath := filepath.Join(fixture.root, "coordinator-signing.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(ed25519.PrivateKey(fixture.coordinatorKey).Seed())+"\n"), 0o600) + signaturePath := filepath.Join(fixture.root, "state", "cp0.sig") + if err := os.MkdirAll(filepath.Dir(signaturePath), 0o700); err != nil { + t.Fatal(err) + } + sign := append(checkpointInitialEvidenceArgs(fixture), + "--checkpoint", checkpointPath, "--signing-request", requestPath, + "--coordinator-signing-key", keyPath, "--out", signaturePath, + ) + runCheckpointCommandCLI(t, append([]string{"--format", "json", "checkpoint", "sign"}, sign...)) + + verify := append(checkpointInitialEvidenceArgs(fixture), "--checkpoint", checkpointPath, "--checkpoint-signature", signaturePath) + result = runCheckpointCommandCLI(t, append([]string{"--format", "json", "checkpoint", "verify"}, verify...)) + inspection := result.CheckpointEvidenceInspection + if inspection == nil || !inspection.FullyVerified || inspection.Sequence != 0 || + inspection.TransitionKind != mpcceremony.CheckpointInitial { + t.Fatalf("evidence inspection = %#v", inspection) + } + storedArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--checkpoint", checkpointPath, "--checkpoint-signature", signaturePath, + ) + result = runCheckpointCommandCLI(t, append([]string{"--format", "json", "checkpoint", "verify-stored"}, storedArgs...)) + if result.CheckpointEvidenceInspection == nil || !result.CheckpointEvidenceInspection.FullyVerified { + t.Fatalf("stored evidence inspection = %#v", result.CheckpointEvidenceInspection) + } +} + +func TestCheckpointArtifactBytesRemainBoundAcrossPathReplacement(t *testing.T) { + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(root, "record.json") + writeDecisionTestFile(t, path, []byte(`{"version":"A"}`), 0o600) + data, ref, err := checkpointArtifactBytes(root, path, maxOperationalRecordBytes) + if err != nil { + t.Fatal(err) + } + replacement := filepath.Join(root, "replacement.json") + writeDecisionTestFile(t, replacement, []byte(`{"version":"B"}`), 0o600) + if err := os.Rename(replacement, path); err != nil { + t.Fatal(err) + } + if ref.Digest != mpcceremony.NewDigest(data) { + t.Fatal("returned checkpoint reference is not bound to the returned validation bytes") + } + current, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if ref.Digest == mpcceremony.NewDigest(current) { + t.Fatal("test replacement did not change the path contents") + } +} + +func TestCheckpointArtifactBytesRejectSymlinkComponents(t *testing.T) { + if runtime.GOOS != "darwin" && runtime.GOOS != "linux" { + t.Skip("Unix checkpoint execution target") + } + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + outside := t.TempDir() + writeDecisionTestFile(t, filepath.Join(outside, "record.json"), []byte(`{"outside":true}`), 0o600) + if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil { + t.Fatal(err) + } + if _, _, err := checkpointArtifactBytes(root, filepath.Join(root, "redirect", "record.json"), maxOperationalRecordBytes); err == nil { + t.Fatal("checkpoint artifact traversal followed a symbolic-link component") + } + writeDecisionTestFile(t, filepath.Join(root, "record.json"), []byte(`{"inside":true}`), 0o600) + linkedRoot := filepath.Join(t.TempDir(), "linked-root") + if err := os.Symlink(root, linkedRoot); err != nil { + t.Fatal(err) + } + if _, _, err := checkpointArtifactBytes(linkedRoot, filepath.Join(linkedRoot, "record.json"), maxOperationalRecordBytes); err == nil { + t.Fatal("checkpoint artifact traversal accepted a symbolic-link artifact root") + } +} + +func TestCheckpointSignRejectsArbitraryValidLookingCheckpoint(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + packet := filepath.Join(fixture.root, "prepared", "cp0") + result := runCheckpointCommandCLI(t, append(append([]string{"--format", "json", "checkpoint", "prepare"}, checkpointInitialEvidenceArgs(fixture)...), "--out-dir", packet)) + checkpointPath := result.Outputs["checkpoint"] + raw := mustReadTestFile(t, checkpointPath) + var checkpoint mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(raw, &checkpoint); err != nil { + t.Fatal(err) + } + checkpoint.RelayReleaseID = "other-valid-release" + changed, err := mpcceremony.MarshalCanonical(checkpoint) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, checkpointPath, changed, 0o600) + keyPath := filepath.Join(fixture.root, "coordinator-signing.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(ed25519.PrivateKey(fixture.coordinatorKey).Seed())+"\n"), 0o600) + args := append(checkpointInitialEvidenceArgs(fixture), + "--checkpoint", checkpointPath, "--signing-request", result.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", filepath.Join(fixture.root, "must-not-exist.sig"), + ) + assertCheckpointCommandFails(t, append([]string{"--format", "json", "checkpoint", "sign"}, args...), "do not equal the checkpoint re-derived") +} + +func TestCheckpointPrepareRejectsWrongOutboundSignature(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) + var cp0 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp0Path), &cp0); err != nil { + t.Fatal(err) + } + participant := fixture.definition.Roster[0].Identity + handoff, err := mpcceremony.NewTransferHandoff( + fixture.definition, mpcceremony.Phase1, 1, cp0.Phase1.HeadRecordID, + []mpcceremony.ArtifactRef{cp0.Phase1.HeadPayload}, fixture.definition.Coordinator, participant, + time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano), time.Now().UTC().Add(time.Hour).Format(time.RFC3339Nano), + ) + if err != nil { + t.Fatal(err) + } + handoffPath := filepath.Join(fixture.root, "custody", "outbound.json") + handoffSignaturePath := filepath.Join(fixture.root, "custody", "outbound.sig") + if err := os.MkdirAll(filepath.Dir(handoffPath), 0o700); err != nil { + t.Fatal(err) + } + participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) + handoffBytes, wrongSignature, err := mpcceremony.SignRecord(handoff, participant.KeyID, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, handoffPath, handoffBytes, 0o600) + writeDecisionTestFile(t, handoffSignaturePath, wrongSignature, 0o600) + + args := checkpointOutboundEvidenceArgs(fixture, cp0Path, cp0SignaturePath, handoffPath, handoffSignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + args = append(args, "--out-dir", filepath.Join(fixture.root, "prepared", "cp1")) + assertCheckpointCommandFails(t, append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "signature") +} + +func TestCheckpointPrepareReceiptAcceptedAuthenticatesInnerEvidence(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) + cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + var cp1 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { + t.Fatal(err) + } + slot := cp1.Submissions[len(cp1.Submissions)-1] + participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) + receipt, err := mpcceremony.NewTransferReceipt(handoff, handoffBytes, mpcceremony.ReceiptReceiver, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + t.Fatal(err) + } + receiptPath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "receipt.json") + receiptSignaturePath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "receipt.sig") + if err := os.MkdirAll(filepath.Dir(receiptPath), 0o700); err != nil { + t.Fatal(err) + } + receiptBytes, receiptSignatureBytes, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, receiptPath, receiptBytes, 0o600) + writeDecisionTestFile(t, receiptSignaturePath, receiptSignatureBytes, 0o600) + receiptRef, err := checkpointArtifactRef(fixture.root, receiptPath) + if err != nil { + t.Fatal(err) + } + receiptSignatureRef, err := checkpointArtifactRef(fixture.root, receiptSignaturePath) + if err != nil { + t.Fatal(err) + } + payloads := checkpointSortedArtifacts(receiptRef, receiptSignatureRef) + cp1Bytes, err := mpcceremony.MarshalCanonical(cp1) + if err != nil { + t.Fatal(err) + } + envelope := mpcceremony.SubmissionEnvelopeV1{ + Schema: mpcceremony.SubmissionEnvelopeSchemaV1, Workflow: cp1.Workflow, + CeremonyID: cp1.CeremonyID, Definition: cp1.Definition, RelayReleaseID: cp1.RelayReleaseID, + SubmitterID: slot.IdentityID, SubmitterKeyID: fixture.definition.Roster[0].Identity.KeyID, + SubmitterRole: mpcceremony.SubmissionRoleParticipant, Kind: slot.Kind, Phase: slot.Phase, Index: slot.Index, + ParentCheckpointSHA256: slot.BasisCheckpointSHA256, AllocationCheckpointSHA256: mpcceremony.NewDigest(cp1Bytes).SHA256, ParentHeadID: slot.ParentHeadID, + AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, Payloads: payloads, + } + envelopePath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "envelope.json") + envelopeSignaturePath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "envelope.sig") + envelopeBytes, envelopeSignatureBytes, err := mpcceremony.SignSubmissionEnvelope(fixture.definition, cp1, slot, envelope, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, envelopePath, envelopeBytes, 0o600) + writeDecisionTestFile(t, envelopeSignaturePath, envelopeSignatureBytes, 0o600) + envelopeRefs, err := checkpointPairRefs(fixture.root, envelopePath, envelopeSignaturePath) + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) + manifestBytes := []byte(`{"files":["receipt.json","receipt.sig"]}`) + writeDecisionTestFile(t, manifestPath, manifestBytes, 0o600) + manifestRef, err := checkpointArtifactRef(fixture.root, manifestPath) + if err != nil { + t.Fatal(err) + } + ack := mpcceremony.SubmissionAcknowledgementV1{ + Schema: mpcceremony.SubmissionAcknowledgementSchemaV1, Workflow: cp1.Workflow, + CeremonyID: cp1.CeremonyID, Definition: cp1.Definition, RelayReleaseID: cp1.RelayReleaseID, + CoordinatorID: fixture.definition.Coordinator.ID, CoordinatorKeyID: fixture.definition.Coordinator.KeyID, + SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, + Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, ParentHeadID: envelope.ParentHeadID, + AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Envelope: envelopeRefs, Manifest: manifestRef, Result: mpcceremony.SubmissionAccepted, + } + ackPath := filepath.Join(fixture.root, "acknowledgements", "receipt.json") + ackSignaturePath := filepath.Join(fixture.root, "acknowledgements", "receipt.sig") + if err := os.MkdirAll(filepath.Dir(ackPath), 0o700); err != nil { + t.Fatal(err) + } + ackBytes, ackSignatureBytes, err := mpcceremony.SignSubmissionAcknowledgement( + fixture.definition, cp1, slot, envelope, envelopeRefs, manifestRef, ack, ed25519.PrivateKey(fixture.coordinatorKey), + ) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, ackPath, ackBytes, 0o600) + writeDecisionTestFile(t, ackSignaturePath, ackSignatureBytes, 0o600) + + args := checkpointReceiptEvidenceArgs(fixture, cp1Path, cp1SignaturePath, envelopePath, envelopeSignaturePath, manifestPath, ackPath, ackSignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + cp2Packet := filepath.Join(fixture.root, "prepared", "cp2") + result := runCheckpointCommandCLI(t, append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", cp2Packet)) + var cp2 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, result.Outputs["checkpoint"]), &cp2); err != nil { + t.Fatal(err) + } + if cp2.Sequence != 2 || cp2.Transition.Kind != mpcceremony.CheckpointPhase1ReceiptAccepted || len(cp2.Submissions) != 2 { + t.Fatalf("cp2 = %#v", cp2) + } + keyPath := filepath.Join(fixture.root, "coordinator-key-for-cp2.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(ed25519.PrivateKey(fixture.coordinatorKey).Seed())+"\n"), 0o600) + cp2SignaturePath := filepath.Join(fixture.root, "state", "signed-cp2.sig") + signArgs := append(args, + "--checkpoint", result.Outputs["checkpoint"], "--signing-request", result.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp2SignaturePath, + ) + runCheckpointCommandCLI(t, append([]string{"--format", "json", "checkpoint", "sign"}, signArgs...)) + stored := runCheckpointCommandCLI(t, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", result.Outputs["checkpoint"], "--checkpoint-signature", cp2SignaturePath, "--artifact-root", fixture.root)) + if stored.CheckpointEvidenceInspection == nil || !stored.CheckpointEvidenceInspection.FullyVerified { + t.Fatalf("stored cp2 evidence inspection = %#v", stored.CheckpointEvidenceInspection) + } + + writeDecisionTestFile(t, receiptSignaturePath, append(receiptSignatureBytes, '\n'), 0o600) + assertCheckpointCommandFails(t, + append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", filepath.Join(fixture.root, "prepared", "cp2-tampered")), + "submission payload", + ) + writeDecisionTestFile(t, receiptSignaturePath, receiptSignatureBytes, 0o600) + writeDecisionTestFile(t, manifestPath, append(manifestBytes, '\n'), 0o600) + assertCheckpointCommandFails(t, + append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", filepath.Join(fixture.root, "prepared", "cp2-tampered-manifest")), + "manifest", + ) + writeDecisionTestFile(t, manifestPath, manifestBytes, 0o600) + if err := os.Remove(receiptSignaturePath); err != nil { + t.Fatal(err) + } + assertCheckpointCommandFails(t, + append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", filepath.Join(fixture.root, "prepared", "cp2-missing-payload")), + "submission payload", + ) +} + +func TestCheckpointCommandFullLifecycleThroughPhase2Turn(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("full signed workflow fixture requires Linux executable identity") + } + fixture, participantKey := writeWorkflowCheckpointCLIFixture(t) + cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) + cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + cp2Path, cp2SignaturePath := prepareAndSignReceiptCheckpoint(t, fixture, participantKey, cp1Path, cp1SignaturePath, handoff, handoffBytes, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + + var cp2 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp2Path), &cp2); err != nil { + t.Fatal(err) + } + var candidateSlot mpcceremony.CheckpointSubmissionSlot + for _, slot := range cp2.Submissions { + if slot.Kind == mpcceremony.CheckpointSubmissionCandidate && slot.Status == mpcceremony.CheckpointSubmissionAllocated { + candidateSlot = slot + } + } + if candidateSlot.AttemptID == "" { + t.Fatal("cp2 has no allocated candidate slot") + } + chainPath := filepath.Join(fixture.root, "phase1", "chain-0001.json") + chainSignaturePath := filepath.Join(fixture.root, "phase1", "chain-0001.sig") + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: fixture.trustArgs[1], DefinitionSignaturePath: fixture.trustArgs[3], CoordinatorPublicKeyPath: fixture.trustArgs[5], + }) + if err != nil { + t.Fatal(err) + } + chain, _, err := mpcceremony.LoadSignedChainExact(trusted, mpcceremony.PhaseTranscriptPaths{ + RootDir: fixture.root, ChainPath: chainPath, ChainSignaturePath: chainSignaturePath, + }) + if err != nil { + t.Fatal(err) + } + accepted := chain.Records[len(chain.Records)-1] + payloads := checkpointSortedArtifacts(accepted.OutputPayload, accepted.Attestation, accepted.AttestationSignature, accepted.Erasure, accepted.ErasureSignature) + envelope := mpcceremony.SubmissionEnvelopeV1{ + Schema: mpcceremony.SubmissionEnvelopeSchemaV1, Workflow: cp2.Workflow, + CeremonyID: cp2.CeremonyID, Definition: cp2.Definition, RelayReleaseID: cp2.RelayReleaseID, + SubmitterID: candidateSlot.IdentityID, SubmitterKeyID: fixture.definition.Roster[0].Identity.KeyID, + SubmitterRole: mpcceremony.SubmissionRoleParticipant, Kind: candidateSlot.Kind, Phase: candidateSlot.Phase, Index: candidateSlot.Index, + ParentCheckpointSHA256: candidateSlot.BasisCheckpointSHA256, + AllocationCheckpointSHA256: mpcceremony.NewDigest(mustReadTestFile(t, cp2Path)).SHA256, + ParentHeadID: candidateSlot.ParentHeadID, + AttemptID: candidateSlot.AttemptID, ManifestKey: candidateSlot.ManifestKey, Payloads: payloads, + } + envelopePath := filepath.Join(fixture.root, "submissions", "candidate", candidateSlot.AttemptID, "envelope.json") + envelopeSignaturePath := filepath.Join(fixture.root, "submissions", "candidate", candidateSlot.AttemptID, "envelope.sig") + if err := os.MkdirAll(filepath.Dir(envelopePath), 0o700); err != nil { + t.Fatal(err) + } + envelopeBytes, envelopeSignatureBytes, err := mpcceremony.SignSubmissionEnvelope(fixture.definition, cp2, candidateSlot, envelope, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, envelopePath, envelopeBytes, 0o600) + writeDecisionTestFile(t, envelopeSignaturePath, envelopeSignatureBytes, 0o600) + envelopeRefs, err := checkpointPairRefs(fixture.root, envelopePath, envelopeSignaturePath) + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(fixture.root, filepath.FromSlash(candidateSlot.ManifestKey)) + manifestBytes := []byte(`{"kind":"candidate","complete":true}`) + writeDecisionTestFile(t, manifestPath, manifestBytes, 0o600) + manifestRef, err := checkpointArtifactRef(fixture.root, manifestPath) + if err != nil { + t.Fatal(err) + } + ack := mpcceremony.SubmissionAcknowledgementV1{ + Schema: mpcceremony.SubmissionAcknowledgementSchemaV1, Workflow: cp2.Workflow, + CeremonyID: cp2.CeremonyID, Definition: cp2.Definition, RelayReleaseID: cp2.RelayReleaseID, + CoordinatorID: fixture.definition.Coordinator.ID, CoordinatorKeyID: fixture.definition.Coordinator.KeyID, + SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, + Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, + AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, + ParentHeadID: envelope.ParentHeadID, + AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Envelope: envelopeRefs, Manifest: manifestRef, Result: mpcceremony.SubmissionAccepted, + } + ackPath := filepath.Join(fixture.root, "acknowledgements", "candidate.json") + ackSignaturePath := filepath.Join(fixture.root, "acknowledgements", "candidate.sig") + if err := os.MkdirAll(filepath.Dir(ackPath), 0o700); err != nil { + t.Fatal(err) + } + ackBytes, ackSignatureBytes, err := mpcceremony.SignSubmissionAcknowledgement( + fixture.definition, cp2, candidateSlot, envelope, envelopeRefs, manifestRef, ack, ed25519.PrivateKey(fixture.coordinatorKey), + ) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, ackPath, ackBytes, 0o600) + writeDecisionTestFile(t, ackSignaturePath, ackSignatureBytes, 0o600) + + args := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointPhase1CandidateAccepted), + "--previous-checkpoint", cp2Path, "--previous-checkpoint-signature", cp2SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", envelopePath, "--transition-record-signature", envelopeSignaturePath, + "--manifest", manifestPath, "--acknowledgement", ackPath, "--acknowledgement-signature", ackSignaturePath, + ) + cp3Packet := filepath.Join(fixture.root, "prepared", "cp3") + result := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", cp3Packet)) + keyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "coordinator.ed25519.private.hex") + cp3SignaturePath := filepath.Join(fixture.root, "state", "signed-cp3.sig") + runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "sign"}, args...), + "--checkpoint", result.Outputs["checkpoint"], "--signing-request", result.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp3SignaturePath)) + verified := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", result.Outputs["checkpoint"], "--checkpoint-signature", cp3SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 3 { + t.Fatalf("cp3 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + + closePath := filepath.Join(fixture.root, "phase1", "closure", "record.json") + closeSignaturePath := filepath.Join(fixture.root, "phase1", "closure", "record.sig") + closureArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointPhase1Closed), + "--previous-checkpoint", result.Outputs["checkpoint"], "--previous-checkpoint-signature", cp3SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", closePath, "--transition-record-signature", closeSignaturePath, + ) + cp4Packet := filepath.Join(fixture.root, "prepared", "cp4") + cp4 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, closureArgs...), "--out-dir", cp4Packet)) + cp4SignaturePath := filepath.Join(fixture.root, "state", "signed-cp4.sig") + runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "sign"}, closureArgs...), + "--checkpoint", cp4.Outputs["checkpoint"], "--signing-request", cp4.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp4SignaturePath)) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp4.Outputs["checkpoint"], "--checkpoint-signature", cp4SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 4 { + t.Fatalf("cp4 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + + beaconArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointPhase1BeaconRecorded), + "--previous-checkpoint", cp4.Outputs["checkpoint"], "--previous-checkpoint-signature", cp4SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", filepath.Join(fixture.root, "phase1", "beacon", "record.json"), + "--transition-record-signature", filepath.Join(fixture.root, "phase1", "beacon", "record.sig"), + ) + cp5Packet := filepath.Join(fixture.root, "prepared", "cp5") + cp5 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, beaconArgs...), "--out-dir", cp5Packet)) + cp5SignaturePath := filepath.Join(fixture.root, "state", "signed-cp5.sig") + runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "sign"}, beaconArgs...), + "--checkpoint", cp5.Outputs["checkpoint"], "--signing-request", cp5.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp5SignaturePath)) + + sealArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointPhase1Sealed), + "--previous-checkpoint", cp5.Outputs["checkpoint"], "--previous-checkpoint-signature", cp5SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", filepath.Join(fixture.root, "phase1", "sealed", "seal.json"), + "--transition-record-signature", filepath.Join(fixture.root, "phase1", "sealed", "seal.sig"), + ) + cp6Packet := filepath.Join(fixture.root, "prepared", "cp6") + cp6 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, sealArgs...), "--out-dir", cp6Packet)) + cp6SignaturePath := filepath.Join(fixture.root, "state", "signed-cp6.sig") + runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "sign"}, sealArgs...), + "--checkpoint", cp6.Outputs["checkpoint"], "--signing-request", cp6.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp6SignaturePath)) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp6.Outputs["checkpoint"], "--checkpoint-signature", cp6SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 6 { + t.Fatalf("cp6 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + phase2Genesis := filepath.Join(fixture.root, "phase2", "genesis.bin") + phase2Chain0 := filepath.Join(fixture.root, "phase2", "chain-0000.json") + phase2Chain0Signature := filepath.Join(fixture.root, "phase2", "chain-0000.sig") + phase2Args := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointPhase2Initialized), + "--previous-checkpoint", cp6.Outputs["checkpoint"], "--previous-checkpoint-signature", cp6SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", phase2Chain0, + "--transition-record-signature", phase2Chain0Signature, + "--phase2-genesis", phase2Genesis, + ) + cp7Packet := filepath.Join(fixture.root, "prepared", "cp7") + cp7 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, phase2Args...), "--out-dir", cp7Packet)) + cp7SignaturePath := filepath.Join(fixture.root, "state", "signed-cp7.sig") + runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "sign"}, phase2Args...), + "--checkpoint", cp7.Outputs["checkpoint"], "--signing-request", cp7.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp7SignaturePath)) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp7.Outputs["checkpoint"], "--checkpoint-signature", cp7SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 7 { + t.Fatalf("cp7 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + phase2Fixture := fixture + phase2Fixture.chainPath, phase2Fixture.chainSignaturePath = chainPath, chainSignaturePath + phase2Fixture.headPayloadPath = filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)) + cp8Path, cp8SignaturePath, phase2Handoff, phase2HandoffBytes := prepareAndSignOutboundCheckpoint(t, phase2Fixture, cp7.Outputs["checkpoint"], cp7SignaturePath, mpcceremony.Phase2, phase2Chain0, phase2Chain0Signature, phase2Genesis) + cp9Path, cp9SignaturePath := prepareAndSignReceiptCheckpoint(t, phase2Fixture, participantKey, cp8Path, cp8SignaturePath, phase2Handoff, phase2HandoffBytes, mpcceremony.Phase2, phase2Chain0, phase2Chain0Signature, phase2Genesis) + phase2Chain1 := filepath.Join(fixture.root, "phase2", "chain-0001.json") + phase2Chain1Signature := filepath.Join(fixture.root, "phase2", "chain-0001.sig") + cp10Path, cp10SignaturePath := prepareAndSignCandidateCheckpoint(t, phase2Fixture, participantKey, cp9Path, cp9SignaturePath, mpcceremony.Phase2, phase2Chain1, phase2Chain1Signature) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp10Path, "--checkpoint-signature", cp10SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 10 { + t.Fatalf("cp10 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + phase2Chain, _, err := mpcceremony.LoadSignedChainExact(trusted, mpcceremony.PhaseTranscriptPaths{ + RootDir: fixture.root, ChainPath: phase2Chain1, ChainSignaturePath: phase2Chain1Signature, + }) + if err != nil { + t.Fatal(err) + } + phase2Accepted := phase2Chain.Records[len(phase2Chain.Records)-1] + phase2ActiveArgs := checkpointActivePhaseArgs( + mpcceremony.Phase2, + phase2Chain1, + phase2Chain1Signature, + filepath.Join(fixture.root, filepath.FromSlash(phase2Accepted.OutputPayload.Name)), + ) + phase2ClosureArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointPhase2Closed), + "--previous-checkpoint", cp10Path, "--previous-checkpoint-signature", cp10SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", filepath.Join(fixture.root, "phase2", "closure", "record.json"), + "--transition-record-signature", filepath.Join(fixture.root, "phase2", "closure", "record.sig"), + ) + phase2ClosureArgs = append(phase2ClosureArgs, phase2ActiveArgs...) + cp11 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, phase2ClosureArgs...), "--out-dir", filepath.Join(fixture.root, "prepared", "cp11"))) + cp11SignaturePath := filepath.Join(fixture.root, "state", "signed-cp11.sig") + runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "sign"}, phase2ClosureArgs...), + "--checkpoint", cp11.Outputs["checkpoint"], "--signing-request", cp11.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp11SignaturePath)) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp11.Outputs["checkpoint"], "--checkpoint-signature", cp11SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 11 { + t.Fatalf("cp11 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + + phase2BeaconArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointPhase2BeaconRecorded), + "--previous-checkpoint", cp11.Outputs["checkpoint"], "--previous-checkpoint-signature", cp11SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", filepath.Join(fixture.root, "phase2", "beacon", "record.json"), + "--transition-record-signature", filepath.Join(fixture.root, "phase2", "beacon", "record.sig"), + ) + phase2BeaconArgs = append(phase2BeaconArgs, phase2ActiveArgs...) + cp12 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, phase2BeaconArgs...), "--out-dir", filepath.Join(fixture.root, "prepared", "cp12"))) + cp12SignaturePath := filepath.Join(fixture.root, "state", "signed-cp12.sig") + runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "sign"}, phase2BeaconArgs...), + "--checkpoint", cp12.Outputs["checkpoint"], "--signing-request", cp12.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp12SignaturePath)) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp12.Outputs["checkpoint"], "--checkpoint-signature", cp12SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 12 { + t.Fatalf("cp12 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + finalCandidateArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointFinalCandidateRecorded), + "--previous-checkpoint", cp12.Outputs["checkpoint"], "--previous-checkpoint-signature", cp12SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--candidate-dir", filepath.Join(fixture.root, "final", "candidate"), + ) + finalCandidateArgs = append(finalCandidateArgs, phase2ActiveArgs...) + cp13 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalCandidateArgs...), "--out-dir", filepath.Join(fixture.root, "prepared", "cp13"))) + cp13SignaturePath := filepath.Join(fixture.root, "state", "signed-cp13.sig") + runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "sign"}, finalCandidateArgs...), + "--checkpoint", cp13.Outputs["checkpoint"], "--signing-request", cp13.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp13SignaturePath)) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp13.Outputs["checkpoint"], "--checkpoint-signature", cp13SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 13 { + t.Fatalf("cp13 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + finalReleaseArgs := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointFinalReleaseRecorded), + "--previous-checkpoint", cp13.Outputs["checkpoint"], "--previous-checkpoint-signature", cp13SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, + "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--release-dir", filepath.Join(fixture.root, "final", "release"), + ) + finalReleaseArgs = append(finalReleaseArgs, phase2ActiveArgs...) + cp14 := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalReleaseArgs...), "--out-dir", filepath.Join(fixture.root, "prepared", "cp14"))) + cp14SignaturePath := filepath.Join(fixture.root, "state", "signed-cp14.sig") + runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "sign"}, finalReleaseArgs...), + "--checkpoint", cp14.Outputs["checkpoint"], "--signing-request", cp14.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", cp14SignaturePath)) + verified = runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), + "--checkpoint", cp14.Outputs["checkpoint"], "--checkpoint-signature", cp14SignaturePath, "--artifact-root", fixture.root)) + if verified.CheckpointEvidenceInspection == nil || !verified.CheckpointEvidenceInspection.FullyVerified || verified.CheckpointEvidenceInspection.Sequence != 14 || + verified.CheckpointEvidenceInspection.TransitionKind != mpcceremony.CheckpointFinalReleaseRecorded { + t.Fatalf("cp14 stored verification = %#v", verified.CheckpointEvidenceInspection) + } + inspected := runCheckpointCommandExecutable(t, fixture.executable, append(append([]string{"--format", "json", "inspect", "checkpoint"}, fixture.trustArgs...), + "--checkpoint", cp14.Outputs["checkpoint"], "--checkpoint-signature", cp14SignaturePath)) + if inspected.CheckpointInspection == nil || inspected.CheckpointInspection.Phase2 == nil || + inspected.CheckpointInspection.Phase2Closure == nil || inspected.CheckpointInspection.Phase2Beacon == nil || + inspected.CheckpointInspection.FinalCandidate == nil || inspected.CheckpointInspection.FinalRelease == nil { + t.Fatalf("final checkpoint inspection omitted authenticated lifecycle state: %#v", inspected.CheckpointInspection) + } + workflowRoot := filepath.Dir(fixture.root) + alternateCandidate := filepath.Join(fixture.root, "final", "candidate-b") + replayCLIArgs := []string{ + "--transcript-root", fixture.root, + "--phase1-chain", chainPath, "--phase1-chain-signature", chainSignaturePath, + "--phase1-close", filepath.Join(fixture.root, "phase1", "closure", "record.json"), + "--phase1-close-signature", filepath.Join(fixture.root, "phase1", "closure", "record.sig"), + "--phase1-beacon", filepath.Join(fixture.root, "phase1", "beacon", "record.json"), + "--phase1-beacon-signature", filepath.Join(fixture.root, "phase1", "beacon", "record.sig"), + "--phase1-seal", filepath.Join(fixture.root, "phase1", "sealed", "seal.json"), + "--phase1-seal-signature", filepath.Join(fixture.root, "phase1", "sealed", "seal.sig"), + "--phase2-chain", phase2Chain1, "--phase2-chain-signature", phase2Chain1Signature, + "--phase2-close", filepath.Join(fixture.root, "phase2", "closure", "record.json"), + "--phase2-close-signature", filepath.Join(fixture.root, "phase2", "closure", "record.sig"), + "--phase2-beacon", filepath.Join(fixture.root, "phase2", "beacon", "record.json"), + "--phase2-beacon-signature", filepath.Join(fixture.root, "phase2", "beacon", "record.sig"), + } + alternateFinalizeArgs := append(append([]string{"--format", "json", "finalize", "complete"}, fixture.trustArgs...), replayCLIArgs...) + alternateFinalizeArgs = append(alternateFinalizeArgs, + "--coordinator-signing-key", keyPath, + "--public-evidence", filepath.Join(workflowRoot, "checkpoint-public-finalization-evidence.json"), + "--finalized-at", "2023-08-23T15:11:35.5Z", "--out-dir", alternateCandidate) + runCheckpointCommandExecutable(t, fixture.executable, alternateFinalizeArgs) + alternateRelease := filepath.Join(fixture.root, "final", "release-b") + alternateReleaseArgs := append(append([]string{"--format", "json", "release", "sign"}, fixture.trustArgs...), replayCLIArgs...) + alternateReleaseArgs = append(alternateReleaseArgs, + "--candidate-bundle", alternateCandidate, "--operational-evidence-root", fixture.root, + "--operational-bundle", filepath.Join(fixture.root, "operational", "evidence-bundle.json"), + "--operational-bundle-signature", filepath.Join(fixture.root, "operational", "evidence-bundle.sig"), + "--release-signing-key", filepath.Join(workflowRoot, "identity-keys", "release-signer.ed25519.private.hex"), + "--signature-key-id", fixture.definition.ReleaseSigner.KeyID, + "--released-at", "2023-08-23T15:11:38.5Z", "--release-dir", alternateRelease) + runCheckpointCommandExecutable(t, fixture.executable, alternateReleaseArgs) + canonicalRelease := filepath.Join(fixture.root, "final", "release") + originalRelease := filepath.Join(fixture.root, "final", "release-a.test-backup") + if err := os.Rename(canonicalRelease, originalRelease); err != nil { + t.Fatal(err) + } + if err := os.Rename(alternateRelease, canonicalRelease); err != nil { + t.Fatal(err) + } + assertCheckpointExecutableFails(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalReleaseArgs...), + "--out-dir", filepath.Join(fixture.root, "prepared", "cp14-different-valid-candidate")), "differs from the checkpointed final candidate") + if err := os.Rename(canonicalRelease, alternateRelease); err != nil { + t.Fatal(err) + } + if err := os.Rename(originalRelease, canonicalRelease); err != nil { + t.Fatal(err) + } + assertChangedCheckpointEvidenceFails(t, fixture.executable, phase2Args, "phase2/genesis.bin", "phase2-genesis") + assertChangedCheckpointEvidenceFails(t, fixture.executable, phase2Args, "phase2/chain-0000.json", "phase2-chain") + assertChangedCheckpointEvidenceFails(t, fixture.executable, phase2Args, "phase2/chain-0000.sig", "phase2-chain-signature") + assertChangedCheckpointEvidenceFails(t, fixture.executable, sealArgs, "phase1/sealed/seal.sig", "seal-signature") + assertChangedCheckpointEvidenceFails(t, fixture.executable, sealArgs, "phase1/sealed/commons.bin", "sealed-commons") + + assertChangedCheckpointEvidenceFails(t, fixture.executable, args, accepted.OutputPayload.Name, "candidate-payload") + assertChangedCheckpointEvidenceFails(t, fixture.executable, args, filepath.ToSlash(mustRelativeTestPath(t, fixture.root, manifestPath)), "manifest") + assertChangedCheckpointEvidenceFails(t, fixture.executable, args, filepath.ToSlash(mustRelativeTestPath(t, fixture.root, ackSignaturePath)), "acknowledgement") + assertChangedCheckpointEvidenceFails(t, fixture.executable, args, filepath.ToSlash(mustRelativeTestPath(t, fixture.root, chainPath)), "accepted-chain") + assertChangedCheckpointEvidenceFails(t, fixture.executable, phase2ClosureArgs, "phase2/closure/record.sig", "phase2-closure-signature") + assertChangedCheckpointEvidenceFails(t, fixture.executable, phase2BeaconArgs, "phase2/beacon/raw-response.bin", "phase2-beacon-response") + for _, testCase := range []struct{ path, label string }{ + {"final/candidate/ownership.pk", "proving-key"}, + {"final/candidate/ownership.vk", "verifying-key"}, + {"final/candidate/candidate.json", "metadata"}, + {"final/candidate/candidate.sig.json", "signature"}, + {"final/candidate/candidate-checksums.sha256", "checksums"}, + {"final/candidate/public-finalization-evidence.json", "public-evidence"}, + } { + assertChangedCheckpointEvidenceFails(t, fixture.executable, finalCandidateArgs, testCase.path, "final-candidate-"+testCase.label) + } + for _, testCase := range []struct{ path, label string }{ + {"final/release/manifest.json", "manifest"}, + {"final/release/manifest.sig", "manifest-signature"}, + {"final/release/setup-transcript.json", "transcript"}, + {"final/release/checksums.sha256", "checksums"}, + {"final/release/operational/evidence-bundle.json", "operational-evidence"}, + } { + assertChangedCheckpointEvidenceFails(t, fixture.executable, finalReleaseArgs, testCase.path, "final-release-"+testCase.label) + } + releaseDir := filepath.Join(fixture.root, "final", "release") + releaseMissing := filepath.Join(releaseDir, "manifest-public-key.hex") + releaseBackup := releaseMissing + ".test-backup" + if err := os.Rename(releaseMissing, releaseBackup); err != nil { + t.Fatal(err) + } + assertCheckpointExecutableFails(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalReleaseArgs...), + "--out-dir", filepath.Join(fixture.root, "prepared", "cp14-missing")), "") + if err := os.Rename(releaseBackup, releaseMissing); err != nil { + t.Fatal(err) + } + releaseExtra := filepath.Join(releaseDir, "unexpected.bin") + writeDecisionTestFile(t, releaseExtra, []byte("unexpected"), 0o600) + assertCheckpointExecutableFails(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalReleaseArgs...), + "--out-dir", filepath.Join(fixture.root, "prepared", "cp14-extra")), "") + if err := os.Remove(releaseExtra); err != nil { + t.Fatal(err) + } + candidateDir := filepath.Join(fixture.root, "final", "candidate") + missingPath := filepath.Join(candidateDir, "ownership.vk") + missingBackup := missingPath + ".test-backup" + if err := os.Rename(missingPath, missingBackup); err != nil { + t.Fatal(err) + } + assertCheckpointExecutableFails(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalCandidateArgs...), + "--out-dir", filepath.Join(fixture.root, "prepared", "cp13-missing")), "") + if err := os.Rename(missingBackup, missingPath); err != nil { + t.Fatal(err) + } + extraPath := filepath.Join(candidateDir, "unexpected.bin") + writeDecisionTestFile(t, extraPath, []byte("unexpected"), 0o600) + assertCheckpointExecutableFails(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalCandidateArgs...), + "--out-dir", filepath.Join(fixture.root, "prepared", "cp13-extra")), "") + if err := os.Remove(extraPath); err != nil { + t.Fatal(err) + } + if err := os.Rename(missingPath, missingBackup); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Base(missingBackup), missingPath); err == nil { + assertCheckpointExecutableFails(t, fixture.executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, finalCandidateArgs...), + "--out-dir", filepath.Join(fixture.root, "prepared", "cp13-symlink")), "") + if err := os.Remove(missingPath); err != nil { + t.Fatal(err) + } + } + if err := os.Rename(missingBackup, missingPath); err != nil { + t.Fatal(err) + } +} + +func prepareAndSignCandidateCheckpoint(t *testing.T, fixture checkpointCLIFixture, participantKey ed25519.PrivateKey, previousPath, previousSignaturePath string, phase mpcceremony.Phase, chainPath, chainSignaturePath string) (string, string) { + t.Helper() + var previous mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, previousPath), &previous); err != nil { + t.Fatal(err) + } + var slot mpcceremony.CheckpointSubmissionSlot + for _, candidate := range previous.Submissions { + if candidate.Phase == phase && candidate.Kind == mpcceremony.CheckpointSubmissionCandidate && candidate.Status == mpcceremony.CheckpointSubmissionAllocated { + slot = candidate + } + } + if slot.AttemptID == "" { + t.Fatalf("%s checkpoint has no allocated candidate slot", phase) + } + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: fixture.trustArgs[1], DefinitionSignaturePath: fixture.trustArgs[3], CoordinatorPublicKeyPath: fixture.trustArgs[5]}) + if err != nil { + t.Fatal(err) + } + chain, _, err := mpcceremony.LoadSignedChainExact(trusted, mpcceremony.PhaseTranscriptPaths{RootDir: fixture.root, ChainPath: chainPath, ChainSignaturePath: chainSignaturePath}) + if err != nil { + t.Fatal(err) + } + accepted := chain.Records[len(chain.Records)-1] + payloads := checkpointSortedArtifacts(accepted.OutputPayload, accepted.Attestation, accepted.AttestationSignature, accepted.Erasure, accepted.ErasureSignature) + previousBytes := mustReadTestFile(t, previousPath) + envelope := mpcceremony.SubmissionEnvelopeV1{ + Schema: mpcceremony.SubmissionEnvelopeSchemaV1, Workflow: previous.Workflow, + CeremonyID: previous.CeremonyID, Definition: previous.Definition, RelayReleaseID: previous.RelayReleaseID, + SubmitterID: slot.IdentityID, SubmitterKeyID: fixture.definition.Roster[0].Identity.KeyID, + SubmitterRole: mpcceremony.SubmissionRoleParticipant, Kind: slot.Kind, Phase: slot.Phase, Index: slot.Index, + ParentCheckpointSHA256: slot.BasisCheckpointSHA256, AllocationCheckpointSHA256: mpcceremony.NewDigest(previousBytes).SHA256, + ParentHeadID: slot.ParentHeadID, AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, Payloads: payloads, + } + envelopeDir := filepath.Join(fixture.root, "submissions", string(phase)+"-candidate", slot.AttemptID) + if err := os.MkdirAll(envelopeDir, 0o700); err != nil { + t.Fatal(err) + } + envelopePath, envelopeSignaturePath := filepath.Join(envelopeDir, "envelope.json"), filepath.Join(envelopeDir, "envelope.sig") + envelopeBytes, envelopeSignatureBytes, err := mpcceremony.SignSubmissionEnvelope(fixture.definition, previous, slot, envelope, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, envelopePath, envelopeBytes, 0o600) + writeDecisionTestFile(t, envelopeSignaturePath, envelopeSignatureBytes, 0o600) + envelopeRefs, err := checkpointPairRefs(fixture.root, envelopePath, envelopeSignaturePath) + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) + manifestBytes := []byte(`{"kind":"candidate","complete":true}`) + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, manifestPath, manifestBytes, 0o600) + manifestRef, err := checkpointArtifactRef(fixture.root, manifestPath) + if err != nil { + t.Fatal(err) + } + ack := mpcceremony.SubmissionAcknowledgementV1{ + Schema: mpcceremony.SubmissionAcknowledgementSchemaV1, Workflow: previous.Workflow, + CeremonyID: previous.CeremonyID, Definition: previous.Definition, RelayReleaseID: previous.RelayReleaseID, + CoordinatorID: fixture.definition.Coordinator.ID, CoordinatorKeyID: fixture.definition.Coordinator.KeyID, + SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, + Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, + ParentHeadID: envelope.ParentHeadID, AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Envelope: envelopeRefs, Manifest: manifestRef, Result: mpcceremony.SubmissionAccepted, + } + ackPath, ackSignaturePath := filepath.Join(envelopeDir, "ack.json"), filepath.Join(envelopeDir, "ack.sig") + ackBytes, ackSignatureBytes, err := mpcceremony.SignSubmissionAcknowledgement(fixture.definition, previous, slot, envelope, envelopeRefs, manifestRef, ack, ed25519.PrivateKey(fixture.coordinatorKey)) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, ackPath, ackBytes, 0o600) + writeDecisionTestFile(t, ackSignaturePath, ackSignatureBytes, 0o600) + kind := mpcceremony.CheckpointPhase1CandidateAccepted + activeArgs := checkpointActivePhaseArgs(phase, chainPath, chainSignaturePath, filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name))) + if phase == mpcceremony.Phase2 { + kind = mpcceremony.CheckpointPhase2CandidateAccepted + } + args := append(append([]string{}, fixture.trustArgs...), "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", + "--transition", string(kind), "--previous-checkpoint", previousPath, "--previous-checkpoint-signature", previousSignaturePath, + "--transition-record", envelopePath, "--transition-record-signature", envelopeSignaturePath, + "--manifest", manifestPath, "--acknowledgement", ackPath, "--acknowledgement-signature", ackSignaturePath) + args = append(args, activeArgs...) + if phase == mpcceremony.Phase2 { + args = append(args, "--chain", fixture.chainPath, "--chain-signature", fixture.chainSignaturePath, "--head-payload", fixture.headPayloadPath) + } + packet := filepath.Join(fixture.root, "prepared", "signed-"+string(phase)+"-candidate") + result := runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", packet)) + keyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "coordinator.ed25519.private.hex") + signaturePath := filepath.Join(fixture.root, "state", "signed-"+string(phase)+"-candidate.sig") + runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "sign"}, args...), + "--checkpoint", result.Outputs["checkpoint"], "--signing-request", result.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", signaturePath)) + return result.Outputs["checkpoint"], signaturePath +} + +func TestVerifyCandidateEnvelopePayloadsStreamsLargeContribution(t *testing.T) { + root := t.TempDir() + write := func(name string, contents []byte) mpcceremony.ArtifactRef { + path := filepath.Join(root, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, path, contents, 0o600) + ref, err := checkpointArtifactRef(root, path) + if err != nil { + t.Fatal(err) + } + return ref + } + large := bytes.Repeat([]byte{0x5a}, (16<<20)+1) + accepted := mpcceremony.ChainRecord{ + OutputPayload: write("phase1/contributions/0001/contribution.bin", large), + Attestation: write("phase1/contributions/0001/attestation.json", []byte(`{"attestation":true}`)), + AttestationSignature: write("phase1/contributions/0001/attestation.sig", []byte(`{"signature":true}`)), + Erasure: write("phase1/contributions/0001/erasure.json", []byte(`{"cleanup":true}`)), + ErasureSignature: write("phase1/contributions/0001/erasure.sig", []byte(`{"signature":true}`)), + } + envelope := mpcceremony.SubmissionEnvelopeV1{Payloads: checkpointSortedArtifacts( + accepted.OutputPayload, accepted.Attestation, accepted.AttestationSignature, accepted.Erasure, accepted.ErasureSignature, + )} + if err := verifyCandidateEnvelopePayloads(root, envelope, accepted); err != nil { + t.Fatalf("stream exact contribution larger than operational-record cap: %v", err) + } + + largePath := filepath.Join(root, filepath.FromSlash(accepted.OutputPayload.Name)) + f, err := os.OpenFile(largePath, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteAt([]byte{0x00}, int64(len(large)/2)); err != nil { + f.Close() + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + if err := verifyCandidateEnvelopePayloads(root, envelope, accepted); err == nil { + t.Fatal("altered large contribution passed exact streaming digest verification") + } + writeDecisionTestFile(t, largePath, large, 0o600) + if err := os.Truncate(largePath, int64(len(large)-1)); err != nil { + t.Fatal(err) + } + if err := verifyCandidateEnvelopePayloads(root, envelope, accepted); err == nil { + t.Fatal("truncated large contribution passed exact streaming digest verification") + } +} + +func TestNextCheckpointParticipantRejectsCompleteMaximumSchedule(t *testing.T) { + participants := make([]string, mpcceremony.MaxParticipants) + for index := range participants { + participants[index] = fmt.Sprintf("participant-%03d", index+1) + } + state := mpcceremony.CheckpointPhaseState{Phase: mpcceremony.Phase2, AcceptedCount: mpcceremony.MaxParticipants} + if _, _, err := nextCheckpointParticipant(state, mpcceremony.PhasePolicy{Participants: participants, Minimum: 1}, mpcceremony.Phase2); err == nil { + t.Fatal("complete 255-participant schedule returned another participant") + } +} + +func TestCheckpointPhase2BeaconMustDifferFromPhase1(t *testing.T) { + phase1Close := mpcceremony.CloseRecord{BeaconProvider: "drand", BeaconNetwork: "quicknet", BeaconRound: 42} + phase2Close := mpcceremony.CloseRecord{BeaconProvider: "drand", BeaconNetwork: "quicknet", BeaconRound: 42} + if err := validateDistinctPhaseCloseRounds(phase1Close, phase2Close); err == nil { + t.Fatal("phase2 closure reused phase1 beacon round") + } + phase2Close.BeaconRound = 43 + if err := validateDistinctPhaseCloseRounds(phase1Close, phase2Close); err != nil { + t.Fatalf("distinct phase closure rounds: %v", err) + } + + phase1Beacon := mpcceremony.BeaconRecord{Provider: "drand", Network: "quicknet", Round: 42, ChallengeSHA256: "sha256:" + strings.Repeat("1", 64)} + phase2Beacon := mpcceremony.BeaconRecord{Provider: "drand", Network: "quicknet", Round: 43, ChallengeSHA256: phase1Beacon.ChallengeSHA256} + if err := validateDistinctPhaseBeaconRecords(phase1Beacon, phase2Beacon); err == nil { + t.Fatal("phase2 beacon reused phase1 challenge") + } + phase2Beacon.ChallengeSHA256 = "sha256:" + strings.Repeat("2", 64) + phase2Beacon.Round = phase1Beacon.Round + if err := validateDistinctPhaseBeaconRecords(phase1Beacon, phase2Beacon); err == nil { + t.Fatal("phase2 beacon reused phase1 provider, network, and round") + } + phase2Beacon.Round = 43 + if err := validateDistinctPhaseBeaconRecords(phase1Beacon, phase2Beacon); err != nil { + t.Fatalf("distinct phase beacon records: %v", err) + } +} + +func checkpointInitialEvidenceArgs(fixture checkpointCLIFixture) []string { + return append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, + "--relay-release-id", "role-images-test", + "--transition", string(mpcceremony.CheckpointInitial), + "--chain", fixture.chainPath, + "--chain-signature", fixture.chainSignaturePath, + "--head-payload", fixture.headPayloadPath, + ) +} + +func writeWorkflowCheckpointCLIFixture(t *testing.T) (checkpointCLIFixture, ed25519.PrivateKey) { + t.Helper() + repoRoot, err := os.Getwd() + if err != nil { + t.Fatal(err) + } + helperPath := os.Getenv("MPC_WORKFLOW_HELPER") + operationalHelperPath := os.Getenv("MPC_OPERATIONAL_HELPER") + if helperPath == "" { + helperPath = filepath.Join(t.TempDir(), "mpc-workflow-helper") + build := exec.Command("go", "build", "-o", helperPath, "./internal/mpcceremony/testdata/workflowhelper") + build.Dir = filepath.Clean(filepath.Join(repoRoot, "..", "..")) + if output, buildErr := build.CombinedOutput(); buildErr != nil { + t.Fatalf("build workflow helper: %v\n%s", buildErr, output) + } + } + if operationalHelperPath == "" { + operationalHelperPath = filepath.Join(t.TempDir(), "mpc-operational-helper") + build := exec.Command("go", "build", "-o", operationalHelperPath, "./scripts/mpc-rehearsal-operational-evidence") + build.Dir = filepath.Clean(filepath.Join(repoRoot, "..", "..")) + if output, buildErr := build.CombinedOutput(); buildErr != nil { + t.Fatalf("build operational helper: %v\n%s", buildErr, output) + } + } + commandPath := filepath.Join(t.TempDir(), "mpc-ceremony") + buildCommand := exec.Command("go", "build", "-o", commandPath, "./cmd/mpc-ceremony") + buildCommand.Dir = filepath.Clean(filepath.Join(repoRoot, "..", "..")) + if output, buildErr := buildCommand.CombinedOutput(); buildErr != nil { + t.Fatalf("build mpc-ceremony command: %v\n%s", buildErr, output) + } + workflowRoot := filepath.Join(t.TempDir(), "workflow") + run := exec.Command(helperPath, workflowRoot, operationalHelperPath) + run.Dir = filepath.Clean(filepath.Join(repoRoot, "..", "..")) + run.Env = append(os.Environ(), + "MPC_CEREMONY_TEST_BINARY="+commandPath, + "MPC_WORKFLOW_PHASE2_ONE=1", + "PROOF_TOOL_TEST_ZERO_ASSURANCE=1", + ) + if output, runErr := run.CombinedOutput(); runErr != nil { + t.Fatalf("run workflow helper: %v\n%s", runErr, output) + } + ceremonyRoot := filepath.Join(workflowRoot, "ceremony") + definitionPath := filepath.Join(ceremonyRoot, "ceremony.json") + definitionSignaturePath := filepath.Join(ceremonyRoot, "ceremony.sig") + coordinatorPublicKeyPath := filepath.Join(workflowRoot, "identity-keys", "trusted-coordinator.ed25519.public.hex") + var definition mpcceremony.CeremonyDefinition + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, definitionPath), &definition); err != nil { + t.Fatal(err) + } + coordinatorKey := readCheckpointTestKey(t, filepath.Join(workflowRoot, "identity-keys", "coordinator.ed25519.private.hex")) + participantKey := readCheckpointTestKey(t, filepath.Join(workflowRoot, "identity-keys", "participant-01.ed25519.private.hex")) + return checkpointCLIFixture{ + executable: commandPath, + trustArgs: []string{ + "--ceremony", definitionPath, + "--ceremony-signature", definitionSignaturePath, + "--coordinator-public-key-file", coordinatorPublicKeyPath, + }, + definition: definition, coordinatorKey: coordinatorKey, root: ceremonyRoot, + chainPath: filepath.Join(ceremonyRoot, "phase1", "chain-0000.json"), + chainSignaturePath: filepath.Join(ceremonyRoot, "phase1", "chain-0000.sig"), + headPayloadPath: filepath.Join(ceremonyRoot, filepath.FromSlash(definition.Phase1Genesis.Name)), + }, participantKey +} + +func readCheckpointTestKey(t *testing.T, path string) ed25519.PrivateKey { + t.Helper() + seed, err := hex.DecodeString(strings.TrimSpace(string(mustReadTestFile(t, path)))) + if err != nil || len(seed) != ed25519.SeedSize { + t.Fatalf("read test key %s: decoded %d bytes, err %v", path, len(seed), err) + } + return ed25519.NewKeyFromSeed(seed) +} + +func prepareAndSignReceiptCheckpoint(t *testing.T, fixture checkpointCLIFixture, participantKey ed25519.PrivateKey, cp1Path, cp1SignaturePath string, handoff mpcceremony.TransferHandoff, handoffBytes []byte, phase mpcceremony.Phase, activeChainPath, activeChainSignaturePath, activeHeadPath string) (string, string) { + t.Helper() + var cp1 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { + t.Fatal(err) + } + slot := cp1.Submissions[len(cp1.Submissions)-1] + receipt, err := mpcceremony.NewTransferReceipt(handoff, handoffBytes, mpcceremony.ReceiptReceiver, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + t.Fatal(err) + } + receiptDir := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID) + if err := os.MkdirAll(receiptDir, 0o700); err != nil { + t.Fatal(err) + } + receiptPath, receiptSignaturePath := filepath.Join(receiptDir, "receipt.json"), filepath.Join(receiptDir, "receipt.sig") + receiptBytes, receiptSignatureBytes, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, receiptPath, receiptBytes, 0o600) + writeDecisionTestFile(t, receiptSignaturePath, receiptSignatureBytes, 0o600) + receiptRef, err := checkpointArtifactRef(fixture.root, receiptPath) + if err != nil { + t.Fatal(err) + } + receiptSignatureRef, err := checkpointArtifactRef(fixture.root, receiptSignaturePath) + if err != nil { + t.Fatal(err) + } + cp1Bytes, err := mpcceremony.MarshalCanonical(cp1) + if err != nil { + t.Fatal(err) + } + envelope := mpcceremony.SubmissionEnvelopeV1{ + Schema: mpcceremony.SubmissionEnvelopeSchemaV1, Workflow: cp1.Workflow, + CeremonyID: cp1.CeremonyID, Definition: cp1.Definition, RelayReleaseID: cp1.RelayReleaseID, + SubmitterID: slot.IdentityID, SubmitterKeyID: fixture.definition.Roster[0].Identity.KeyID, + SubmitterRole: mpcceremony.SubmissionRoleParticipant, Kind: slot.Kind, Phase: slot.Phase, Index: slot.Index, + ParentCheckpointSHA256: slot.BasisCheckpointSHA256, + AllocationCheckpointSHA256: mpcceremony.NewDigest(cp1Bytes).SHA256, + ParentHeadID: slot.ParentHeadID, + AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, + Payloads: checkpointSortedArtifacts(receiptRef, receiptSignatureRef), + } + envelopePath, envelopeSignaturePath := filepath.Join(receiptDir, "envelope.json"), filepath.Join(receiptDir, "envelope.sig") + envelopeBytes, envelopeSignatureBytes, err := mpcceremony.SignSubmissionEnvelope(fixture.definition, cp1, slot, envelope, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, envelopePath, envelopeBytes, 0o600) + writeDecisionTestFile(t, envelopeSignaturePath, envelopeSignatureBytes, 0o600) + envelopeRefs, err := checkpointPairRefs(fixture.root, envelopePath, envelopeSignaturePath) + if err != nil { + t.Fatal(err) + } + manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) + manifestBytes := []byte(`{"kind":"receipt","complete":true}`) + writeDecisionTestFile(t, manifestPath, manifestBytes, 0o600) + manifestRef, err := checkpointArtifactRef(fixture.root, manifestPath) + if err != nil { + t.Fatal(err) + } + ack := mpcceremony.SubmissionAcknowledgementV1{ + Schema: mpcceremony.SubmissionAcknowledgementSchemaV1, Workflow: cp1.Workflow, + CeremonyID: cp1.CeremonyID, Definition: cp1.Definition, RelayReleaseID: cp1.RelayReleaseID, + CoordinatorID: fixture.definition.Coordinator.ID, CoordinatorKeyID: fixture.definition.Coordinator.KeyID, + SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, + Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, + AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, + ParentHeadID: envelope.ParentHeadID, + AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Envelope: envelopeRefs, Manifest: manifestRef, Result: mpcceremony.SubmissionAccepted, + } + ackDir := filepath.Join(fixture.root, "acknowledgements") + if err := os.MkdirAll(ackDir, 0o700); err != nil { + t.Fatal(err) + } + ackPath, ackSignaturePath := filepath.Join(ackDir, string(phase)+"-receipt-real.json"), filepath.Join(ackDir, string(phase)+"-receipt-real.sig") + ackBytes, ackSignatureBytes, err := mpcceremony.SignSubmissionAcknowledgement(fixture.definition, cp1, slot, envelope, envelopeRefs, manifestRef, ack, ed25519.PrivateKey(fixture.coordinatorKey)) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, ackPath, ackBytes, 0o600) + writeDecisionTestFile(t, ackSignaturePath, ackSignatureBytes, 0o600) + args := checkpointReceiptEvidenceArgs(fixture, cp1Path, cp1SignaturePath, envelopePath, envelopeSignaturePath, manifestPath, ackPath, ackSignaturePath, phase, activeChainPath, activeChainSignaturePath, activeHeadPath) + packet := filepath.Join(fixture.root, "prepared", "signed-"+string(phase)+"-receipt") + result := runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", packet)) + keyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "coordinator.ed25519.private.hex") + signaturePath := filepath.Join(fixture.root, "state", "signed-"+string(phase)+"-receipt.sig") + runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "sign"}, args...), + "--checkpoint", result.Outputs["checkpoint"], "--signing-request", result.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", signaturePath)) + return result.Outputs["checkpoint"], signaturePath +} + +func assertChangedCheckpointEvidenceFails(t *testing.T, executable string, args []string, relativePath, label string) { + t.Helper() + path := filepath.Join(argsValueForTest(t, args, "--artifact-root"), filepath.FromSlash(relativePath)) + original := mustReadTestFile(t, path) + changed := append([]byte(nil), original...) + changed[len(changed)/2] ^= 1 + writeDecisionTestFile(t, path, changed, 0o600) + assertCheckpointExecutableFails(t, executable, append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), + "--out-dir", filepath.Join(argsValueForTest(t, args, "--artifact-root"), "prepared", "cp3-tampered-"+label)), "") + writeDecisionTestFile(t, path, original, 0o600) +} + +func argsValueForTest(t *testing.T, args []string, name string) string { + t.Helper() + for i := range args { + if args[i] == name && i+1 < len(args) { + return args[i+1] + } + } + t.Fatalf("missing argument %s", name) + return "" +} + +func mustRelativeTestPath(t *testing.T, root, path string) string { + t.Helper() + rel, err := filepath.Rel(root, path) + if err != nil { + t.Fatal(err) + } + return rel +} + +func checkpointActivePhaseArgs(phase mpcceremony.Phase, chainPath, chainSignaturePath, headPath string) []string { + if phase == mpcceremony.Phase1 { + return []string{"--chain", chainPath, "--chain-signature", chainSignaturePath, "--head-payload", headPath} + } + return []string{"--phase2-chain", chainPath, "--phase2-chain-signature", chainSignaturePath, "--phase2-head-payload", headPath} +} + +func checkpointOutboundEvidenceArgs(fixture checkpointCLIFixture, cp0Path, cp0SignaturePath, handoffPath, handoffSignaturePath string, phase mpcceremony.Phase, activeChainPath, activeChainSignaturePath, activeHeadPath string) []string { + kind := mpcceremony.CheckpointPhase1OutboundPublished + attemptID := strings.Repeat("a", 32) + if phase == mpcceremony.Phase2 { + kind = mpcceremony.CheckpointPhase2OutboundPublished + attemptID = strings.Repeat("c", 32) + } + args := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, + "--relay-release-id", "role-images-test", + "--transition", string(kind), + "--previous-checkpoint", cp0Path, + "--previous-checkpoint-signature", cp0SignaturePath, + "--transition-record", handoffPath, + "--transition-record-signature", handoffSignaturePath, + "--attempt-id", attemptID, + "--manifest-key", "submissions/receipt/"+attemptID+"/manifest.json", + ) + args = append(args, checkpointActivePhaseArgs(phase, activeChainPath, activeChainSignaturePath, activeHeadPath)...) + if phase == mpcceremony.Phase2 { + args = append(args, "--chain", fixture.chainPath, "--chain-signature", fixture.chainSignaturePath, "--head-payload", fixture.headPayloadPath) + } + return args +} + +func checkpointReceiptEvidenceArgs(fixture checkpointCLIFixture, cp1Path, cp1SignaturePath, envelopePath, envelopeSignaturePath, manifestPath, ackPath, ackSignaturePath string, phase mpcceremony.Phase, activeChainPath, activeChainSignaturePath, activeHeadPath string) []string { + kind := mpcceremony.CheckpointPhase1ReceiptAccepted + nextAttemptID := strings.Repeat("b", 32) + if phase == mpcceremony.Phase2 { + kind = mpcceremony.CheckpointPhase2ReceiptAccepted + nextAttemptID = strings.Repeat("d", 32) + } + args := append(append([]string{}, fixture.trustArgs...), + "--artifact-root", fixture.root, + "--relay-release-id", "role-images-test", + "--transition", string(kind), + "--previous-checkpoint", cp1Path, + "--previous-checkpoint-signature", cp1SignaturePath, + "--transition-record", envelopePath, + "--transition-record-signature", envelopeSignaturePath, + "--manifest", manifestPath, + "--acknowledgement", ackPath, + "--acknowledgement-signature", ackSignaturePath, + "--next-attempt-id", nextAttemptID, + "--next-manifest-key", "submissions/candidate/"+nextAttemptID+"/manifest.json", + ) + args = append(args, checkpointActivePhaseArgs(phase, activeChainPath, activeChainSignaturePath, activeHeadPath)...) + if phase == mpcceremony.Phase2 { + args = append(args, "--chain", fixture.chainPath, "--chain-signature", fixture.chainSignaturePath, "--head-payload", fixture.headPayloadPath) + } + return args +} + +func prepareAndSignInitialCheckpoint(t *testing.T, fixture checkpointCLIFixture) (string, string) { + t.Helper() + packet := filepath.Join(fixture.root, "prepared", "signed-cp0") + result := runCheckpointCommandCLI(t, append(append([]string{"--format", "json", "checkpoint", "prepare"}, checkpointInitialEvidenceArgs(fixture)...), "--out-dir", packet)) + keyPath := filepath.Join(fixture.root, "coordinator-key-for-cp0.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(ed25519.PrivateKey(fixture.coordinatorKey).Seed())+"\n"), 0o600) + signaturePath := filepath.Join(fixture.root, "state", "signed-cp0.sig") + if err := os.MkdirAll(filepath.Dir(signaturePath), 0o700); err != nil { + t.Fatal(err) + } + args := append(checkpointInitialEvidenceArgs(fixture), + "--checkpoint", result.Outputs["checkpoint"], "--signing-request", result.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", signaturePath, + ) + runCheckpointCommandCLI(t, append([]string{"--format", "json", "checkpoint", "sign"}, args...)) + return result.Outputs["checkpoint"], signaturePath +} + +func prepareAndSignOutboundCheckpoint(t *testing.T, fixture checkpointCLIFixture, cp0Path, cp0SignaturePath string, phase mpcceremony.Phase, activeChainPath, activeChainSignaturePath, activeHeadPath string) (string, string, mpcceremony.TransferHandoff, []byte) { + t.Helper() + var cp0 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp0Path), &cp0); err != nil { + t.Fatal(err) + } + state := cp0.Phase1 + if phase == mpcceremony.Phase2 { + if cp0.Phase2 == nil { + t.Fatal("phase2 outbound parent has no phase2 state") + } + state = *cp0.Phase2 + } + handoff, err := mpcceremony.NewTransferHandoff( + fixture.definition, phase, 1, state.HeadRecordID, + []mpcceremony.ArtifactRef{state.HeadPayload}, fixture.definition.Coordinator, fixture.definition.Roster[0].Identity, + time.Now().UTC().Add(-time.Minute).Format(time.RFC3339Nano), time.Now().UTC().Add(time.Hour).Format(time.RFC3339Nano), + ) + if err != nil { + t.Fatal(err) + } + handoffPath := filepath.Join(fixture.root, "custody", string(phase)+"-valid-outbound.json") + handoffSignaturePath := filepath.Join(fixture.root, "custody", string(phase)+"-valid-outbound.sig") + if err := os.MkdirAll(filepath.Dir(handoffPath), 0o700); err != nil { + t.Fatal(err) + } + handoffBytes, handoffSignatureBytes, err := mpcceremony.SignRecord(handoff, fixture.definition.Coordinator.KeyID, ed25519.PrivateKey(fixture.coordinatorKey)) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, handoffPath, handoffBytes, 0o600) + writeDecisionTestFile(t, handoffSignaturePath, handoffSignatureBytes, 0o600) + args := checkpointOutboundEvidenceArgs(fixture, cp0Path, cp0SignaturePath, handoffPath, handoffSignaturePath, phase, activeChainPath, activeChainSignaturePath, activeHeadPath) + packet := filepath.Join(fixture.root, "prepared", "signed-"+string(phase)+"-outbound") + result := runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "prepare"}, args...), "--out-dir", packet)) + keyPath := filepath.Join(fixture.root, "coordinator-key-for-cp1.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(ed25519.PrivateKey(fixture.coordinatorKey).Seed())+"\n"), 0o600) + signaturePath := filepath.Join(fixture.root, "state", "signed-"+string(phase)+"-outbound.sig") + signArgs := append(args, + "--checkpoint", result.Outputs["checkpoint"], "--signing-request", result.Outputs["signing_request"], + "--coordinator-signing-key", keyPath, "--out", signaturePath, + ) + runCheckpointFixtureCommand(t, fixture, append([]string{"--format", "json", "checkpoint", "sign"}, signArgs...)) + return result.Outputs["checkpoint"], signaturePath, handoff, handoffBytes +} + +func runCheckpointCommandCLI(t *testing.T, args []string) CommandResult { + t.Helper() + 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) + } + return result +} + +func runCheckpointFixtureCommand(t *testing.T, fixture checkpointCLIFixture, args []string) CommandResult { + t.Helper() + if fixture.executable != "" { + return runCheckpointCommandExecutable(t, fixture.executable, args) + } + return runCheckpointCommandCLI(t, args) +} + +func runCheckpointCommandExecutable(t *testing.T, executable string, args []string) CommandResult { + t.Helper() + var stdout, stderr bytes.Buffer + command := exec.Command(executable, args...) + command.Stdout, command.Stderr = &stdout, &stderr + err := command.Run() + if err != nil { + t.Fatalf("run %s: %v, stdout = %q, stderr = %q", executable, err, stdout.String(), stderr.String()) + } + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode %s output: %v, stdout = %q, stderr = %q", executable, err, stdout.String(), stderr.String()) + } + return result +} + +func assertCheckpointExecutableFails(t *testing.T, executable string, args []string, want string) { + t.Helper() + output, err := exec.Command(executable, args...).CombinedOutput() + if err == nil { + t.Fatalf("command unexpectedly succeeded: %s", output) + } + if !strings.Contains(string(output), want) { + t.Fatalf("error %q does not contain %q", output, want) + } +} + +func assertCheckpointCommandFails(t *testing.T, args []string, want string) { + t.Helper() + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatalf("command unexpectedly succeeded: %s", stdout.String()) + } + if combined := stdout.String() + stderr.String(); !strings.Contains(combined, want) { + t.Fatalf("error %q does not contain %q", combined, want) + } +} diff --git a/cmd/mpc-ceremony/checkpoint_inspect_test.go b/cmd/mpc-ceremony/checkpoint_inspect_test.go new file mode 100644 index 00000000..e179e3c6 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_inspect_test.go @@ -0,0 +1,283 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" + + "proof-tool/internal/mpcceremony" +) + +type checkpointCLIFixture struct { + executable string + trustArgs []string + checkpoint0Path string + checkpoint0SigPath string + checkpoint1Path string + checkpoint1SigPath string + definition mpcceremony.CeremonyDefinition + checkpoint0 mpcceremony.Checkpoint + checkpoint1 mpcceremony.Checkpoint + checkpoint0Bytes []byte + checkpoint0Sig []byte + coordinatorKey []byte + root string + chainPath string + chainSignaturePath string + headPayloadPath string +} + +func TestInspectCheckpointAndTransition(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + + checkpointArgs := append( + append([]string{"--format", "json", "inspect", "checkpoint"}, fixture.trustArgs...), + "--checkpoint", fixture.checkpoint1Path, + "--checkpoint-signature", fixture.checkpoint1SigPath, + ) + result := runCheckpointCLI(t, checkpointArgs) + inspection := result.CheckpointInspection + if inspection == nil || inspection.Schema != checkpointInspectionSchema || + inspection.CeremonyID != fixture.definition.CeremonyID || inspection.Sequence != 1 || + inspection.Transition.Kind != mpcceremony.CheckpointPhase1OutboundPublished || + inspection.Digest != mpcceremony.NewDigest(mustReadTestFile(t, fixture.checkpoint1Path)) || + len(inspection.Submissions) != 1 { + t.Fatalf("checkpoint inspection = %#v", inspection) + } + + transitionArgs := checkpointTransitionArgs(fixture) + result = runCheckpointCLI(t, transitionArgs) + transition := result.CheckpointTransitionInspection + if transition == nil || transition.Schema != checkpointTransitionInspectionSchema || + transition.PreviousSequence != 0 || transition.Sequence != 1 || + transition.PreviousCheckpointDigest != mpcceremony.NewDigest(fixture.checkpoint0Bytes) || + transition.PreviousSignatureDigest != mpcceremony.NewDigest(fixture.checkpoint0Sig) || + transition.Checkpoint.Sequence != 1 { + t.Fatalf("transition inspection = %#v", transition) + } +} + +func TestInspectCheckpointTransitionRejectsWrongParentSignatureReference(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + checkpoint := fixture.checkpoint1 + checkpoint.PreviousCheckpoint.Signature.Digest = mpcceremony.NewDigest([]byte("different detached signature")) + checkpointBytes, checkpointSignature, err := mpcceremony.SignRecord( + checkpoint, + fixture.definition.Coordinator.KeyID, + fixture.coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, fixture.checkpoint1Path, checkpointBytes, 0o600) + writeDecisionTestFile(t, fixture.checkpoint1SigPath, checkpointSignature, 0o600) + + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), checkpointTransitionArgs(fixture), &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatalf("transition with wrong parent signature reference accepted: %s", stdout.String()) + } + if !strings.Contains(stdout.String()+stderr.String(), "does not bind the exact previous checkpoint signature") { + t.Fatalf("stdout = %q, stderr = %q", stdout.String(), stderr.String()) + } +} + +func TestInspectCheckpointRejectsTamperedSignatureWithoutProjection(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + writeDecisionTestFile(t, fixture.checkpoint1SigPath, append(mustReadTestFile(t, fixture.checkpoint1SigPath), '\n'), 0o600) + args := append( + append([]string{"--format", "json", "inspect", "checkpoint"}, fixture.trustArgs...), + "--checkpoint", fixture.checkpoint1Path, + "--checkpoint-signature", fixture.checkpoint1SigPath, + ) + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatalf("tampered checkpoint signature accepted: %s", stdout.String()) + } + if strings.Contains(stdout.String(), "checkpoint_inspection") { + t.Fatalf("unauthenticated checkpoint projection emitted: %s", stdout.String()) + } +} + +func checkpointTransitionArgs(fixture checkpointCLIFixture) []string { + return append( + append([]string{"--format", "json", "inspect", "checkpoint-transition"}, fixture.trustArgs...), + "--previous-checkpoint", fixture.checkpoint0Path, + "--previous-checkpoint-signature", fixture.checkpoint0SigPath, + "--checkpoint", fixture.checkpoint1Path, + "--checkpoint-signature", fixture.checkpoint1SigPath, + ) +} + +func runCheckpointCLI(t *testing.T, args []string) CommandResult { + t.Helper() + 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) + } + if !result.OK { + t.Fatalf("result = %#v", result) + } + return result +} + +func writeCheckpointCLIFixture(t *testing.T) checkpointCLIFixture { + t.Helper() + 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) + } + definitionRefs := mpcceremony.SignedArtifactRefs{ + Record: checkpointCLIArtifact("ceremony.json", definitionBytes), + Signature: checkpointCLIArtifact("ceremony.sig", definitionSignature), + } + chainRefs := mpcceremony.SignedArtifactRefs{ + Record: checkpointCLIArtifact("phase1/chain-0000.json", chainBytes), + Signature: checkpointCLIArtifact("phase1/chain-0000.sig", chainSignature), + } + accepted := []mpcceremony.ArtifactRef{ + definitionRefs.Record, + definitionRefs.Signature, + definition.Phase1Genesis, + chainRefs.Record, + chainRefs.Signature, + } + sortCheckpointCLIArtifacts(accepted) + cp0 := mpcceremony.Checkpoint{ + Schema: mpcceremony.CheckpointSchema, + Workflow: mpcceremony.StorageFirstWorkflowV1, + CeremonyID: definition.CeremonyID, + Definition: definitionRefs, + AssurancePolicy: definition.AssurancePolicy, + RelayReleaseID: "role-images-test", + Sequence: 0, + Transition: mpcceremony.CheckpointTransition{Kind: mpcceremony.CheckpointInitial}, + Phase1: mpcceremony.CheckpointPhaseState{ + Phase: mpcceremony.Phase1, AcceptedCount: 0, + HeadRecordID: mpcceremony.NewDigest([]byte("phase1 genesis head")).SHA256, + HeadPayload: definition.Phase1Genesis, Chain: chainRefs, + }, + AcceptedArtifacts: accepted, + Submissions: []mpcceremony.CheckpointSubmissionSlot{}, + } + cp0Bytes, cp0Signature, err := mpcceremony.SignRecord(cp0, definition.Coordinator.KeyID, coordinatorKey) + if err != nil { + t.Fatal(err) + } + + handoffBytes := []byte("canonical outbound handoff") + handoffSignature := []byte("detached outbound handoff signature") + handoff := mpcceremony.SignedArtifactRefs{ + Record: checkpointCLIArtifact("custody/outbound.json", handoffBytes), + Signature: checkpointCLIArtifact("custody/outbound.sig", handoffSignature), + } + cp0Ref := mpcceremony.SignedArtifactRefs{ + Record: checkpointCLIArtifact("state/checkpoint-0000.json", cp0Bytes), + Signature: checkpointCLIArtifact("state/checkpoint-0000.sig", cp0Signature), + } + cp1 := cp0 + cp1.Sequence = 1 + cp1.PreviousCheckpoint = &cp0Ref + cp1.Transition = mpcceremony.CheckpointTransition{ + Kind: mpcceremony.CheckpointPhase1OutboundPublished, Phase: mpcceremony.Phase1, + Index: 1, ParticipantID: definition.Phase1Policy.Participants[0], + AttemptID: strings.Repeat("a", 32), Record: &handoff, + } + cp1.AcceptedArtifacts = append(append([]mpcceremony.ArtifactRef(nil), cp0.AcceptedArtifacts...), handoff.Record, handoff.Signature) + sortCheckpointCLIArtifacts(cp1.AcceptedArtifacts) + cp1.Submissions = []mpcceremony.CheckpointSubmissionSlot{{ + Kind: mpcceremony.CheckpointSubmissionReceipt, Phase: mpcceremony.Phase1, Index: 1, + IdentityID: definition.Phase1Policy.Participants[0], AttemptID: strings.Repeat("a", 32), + ManifestKey: "submissions/receipt/" + strings.Repeat("a", 32) + "/manifest.json", + BasisCheckpointSHA256: cp0Ref.Record.Digest.SHA256, + ParentHeadID: cp0.Phase1.HeadRecordID, Status: mpcceremony.CheckpointSubmissionAllocated, + }} + cp1Bytes, cp1Signature, err := mpcceremony.SignRecord(cp1, 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") + cp0Path := filepath.Join(root, "checkpoint-0000.json") + cp0SigPath := filepath.Join(root, "checkpoint-0000.sig") + cp1Path := filepath.Join(root, "checkpoint-0001.json") + cp1SigPath := filepath.Join(root, "checkpoint-0001.sig") + writeDecisionTestFile(t, ceremonyPath, definitionBytes, 0o600) + writeDecisionTestFile(t, ceremonySignaturePath, definitionSignature, 0o600) + writeDecisionTestFile(t, coordinatorPublicKeyPath, []byte(definition.Coordinator.Ed25519PublicKeyHex+"\n"), 0o600) + writeDecisionTestFile(t, cp0Path, cp0Bytes, 0o600) + writeDecisionTestFile(t, cp0SigPath, cp0Signature, 0o600) + writeDecisionTestFile(t, cp1Path, cp1Bytes, 0o600) + writeDecisionTestFile(t, cp1SigPath, cp1Signature, 0o600) + chainPath := filepath.Join(root, filepath.FromSlash(chainRefs.Record.Name)) + chainSignaturePath := filepath.Join(root, filepath.FromSlash(chainRefs.Signature.Name)) + headPayloadPath := filepath.Join(root, filepath.FromSlash(definition.Phase1Genesis.Name)) + if err := os.MkdirAll(filepath.Dir(chainPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, chainPath, chainBytes, 0o600) + writeDecisionTestFile(t, chainSignaturePath, chainSignature, 0o600) + writeDecisionTestFile(t, headPayloadPath, []byte("genesis"), 0o600) + + return checkpointCLIFixture{ + trustArgs: []string{ + "--ceremony", ceremonyPath, + "--ceremony-signature", ceremonySignaturePath, + "--coordinator-public-key-file", coordinatorPublicKeyPath, + }, + checkpoint0Path: cp0Path, checkpoint0SigPath: cp0SigPath, + checkpoint1Path: cp1Path, checkpoint1SigPath: cp1SigPath, + definition: definition, checkpoint0: cp0, checkpoint1: cp1, + checkpoint0Bytes: cp0Bytes, checkpoint0Sig: cp0Signature, + coordinatorKey: append([]byte(nil), coordinatorKey...), + root: root, chainPath: chainPath, chainSignaturePath: chainSignaturePath, headPayloadPath: headPayloadPath, + } +} + +func checkpointCLIArtifact(name string, contents []byte) mpcceremony.ArtifactRef { + return mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.NewDigest(contents)} +} + +func sortCheckpointCLIArtifacts(values []mpcceremony.ArtifactRef) { + slices.SortFunc(values, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) +} + +func mustReadTestFile(t *testing.T, path string) []byte { + t.Helper() + contents, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return contents +} diff --git a/cmd/mpc-ceremony/checkpoint_open_other.go b/cmd/mpc-ceremony/checkpoint_open_other.go new file mode 100644 index 00000000..c941d374 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_open_other.go @@ -0,0 +1,44 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +//go:build !darwin && !linux + +package main + +import ( + "errors" + "fmt" + "io" + "os" +) + +// Non-Unix builds are not ceremony execution targets. Keep compilation and +// root confinement without claiming the Unix no-follow guarantee. +func openCheckpointArtifactBytes(root, relative string, limit int64) ([]byte, error) { + openedRoot, err := os.OpenRoot(root) + if err != nil { + return nil, err + } + defer openedRoot.Close() + file, err := openedRoot.Open(relative) + if err != nil { + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > limit { + return nil, fmt.Errorf("checkpoint artifact size %d is outside [1,%d] or is not regular", info.Size(), limit) + } + data := make([]byte, info.Size()) + if _, err := io.ReadFull(file, data); err != nil { + return nil, err + } + var extra [1]byte + if n, err := file.Read(extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) { + return nil, errors.New("checkpoint artifact changed while being read") + } + return data, nil +} diff --git a/cmd/mpc-ceremony/checkpoint_open_unix.go b/cmd/mpc-ceremony/checkpoint_open_unix.go new file mode 100644 index 00000000..85a66294 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_open_unix.go @@ -0,0 +1,74 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +//go:build darwin || linux + +package main + +import ( + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strings" + + "golang.org/x/sys/unix" +) + +// openCheckpointArtifactBytes walks beneath an already-open root with +// O_NOFOLLOW on every component. No pathname component can be exchanged for a +// symlink between validation and use. +func openCheckpointArtifactBytes(root, relative string, limit int64) ([]byte, error) { + rootFD, err := unix.Open(root, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + currentFD := rootFD + defer func() { _ = unix.Close(currentFD) }() + parts := strings.Split(filepath.ToSlash(relative), "/") + if len(parts) == 0 || parts[len(parts)-1] == "" || parts[len(parts)-1] == "." { + return nil, errors.New("checkpoint artifact path must name a file") + } + for _, part := range parts[:len(parts)-1] { + if part == "" || part == "." || part == ".." { + return nil, errors.New("checkpoint artifact path has an invalid component") + } + nextFD, openErr := unix.Openat(currentFD, part, unix.O_RDONLY|unix.O_DIRECTORY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if openErr != nil { + return nil, openErr + } + _ = unix.Close(currentFD) + currentFD = nextFD + } + leaf := parts[len(parts)-1] + if leaf == ".." { + return nil, errors.New("checkpoint artifact path has an invalid component") + } + fileFD, err := unix.Openat(currentFD, leaf, unix.O_RDONLY|unix.O_NOFOLLOW|unix.O_CLOEXEC, 0) + if err != nil { + return nil, err + } + file := os.NewFile(uintptr(fileFD), relative) + if file == nil { + _ = unix.Close(fileFD) + return nil, errors.New("open checkpoint artifact file descriptor") + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + if !info.Mode().IsRegular() || info.Size() <= 0 || info.Size() > limit { + return nil, fmt.Errorf("checkpoint artifact size %d is outside [1,%d] or is not regular", info.Size(), limit) + } + data := make([]byte, info.Size()) + if _, err := io.ReadFull(file, data); err != nil { + return nil, err + } + var extra [1]byte + if n, err := file.Read(extra[:]); n != 0 || (err != nil && !errors.Is(err, io.EOF)) { + return nil, errors.New("checkpoint artifact changed while being read") + } + return data, nil +} diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index c535cddf..b9047149 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -13,6 +13,24 @@ import ( "testing" ) +var cliTestReplayFlags = []string{ + "--transcript-root", "transcript", + "--phase1-chain", "transcript/phase1/chain.json", + "--phase1-chain-signature", "transcript/phase1/chain.sig", + "--phase1-close", "transcript/phase1/close.json", + "--phase1-close-signature", "transcript/phase1/close.sig", + "--phase1-beacon", "transcript/phase1/beacon.json", + "--phase1-beacon-signature", "transcript/phase1/beacon.sig", + "--phase1-seal", "transcript/phase1/seal.json", + "--phase1-seal-signature", "transcript/phase1/seal.sig", + "--phase2-chain", "transcript/phase2/chain.json", + "--phase2-chain-signature", "transcript/phase2/chain.sig", + "--phase2-close", "transcript/phase2/close.json", + "--phase2-close-signature", "transcript/phase2/close.sig", + "--phase2-beacon", "transcript/phase2/beacon.json", + "--phase2-beacon-signature", "transcript/phase2/beacon.sig", +} + func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { t.Parallel() @@ -46,23 +64,7 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { "--coordinator-signing-key", "private/coordinator.key", "--beacon-round", "12345", } - replayFlags := []string{ - "--transcript-root", "transcript", - "--phase1-chain", "transcript/phase1/chain.json", - "--phase1-chain-signature", "transcript/phase1/chain.sig", - "--phase1-close", "transcript/phase1/close.json", - "--phase1-close-signature", "transcript/phase1/close.sig", - "--phase1-beacon", "transcript/phase1/beacon.json", - "--phase1-beacon-signature", "transcript/phase1/beacon.sig", - "--phase1-seal", "transcript/phase1/seal.json", - "--phase1-seal-signature", "transcript/phase1/seal.sig", - "--phase2-chain", "transcript/phase2/chain.json", - "--phase2-chain-signature", "transcript/phase2/chain.sig", - "--phase2-close", "transcript/phase2/close.json", - "--phase2-close-signature", "transcript/phase2/close.sig", - "--phase2-beacon", "transcript/phase2/beacon.json", - "--phase2-beacon-signature", "transcript/phase2/beacon.sig", - } + replayFlags := cliTestReplayFlags tests := []struct { name string @@ -272,6 +274,7 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { args: joinArgs( []string{"release", "sign"}, ceremonyTrust, + replayFlags, []string{ "--candidate-bundle", "candidate/release", "--audit-report", "audits/auditor-01.json", @@ -462,6 +465,32 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { ), command: CommandInspectEnrollment, }, + { + name: "inspect checkpoint", + args: joinArgs( + []string{"inspect", "checkpoint"}, + ceremonyTrust, + []string{ + "--checkpoint", "state/checkpoint-0000.json", + "--checkpoint-signature", "state/checkpoint-0000.sig", + }, + ), + command: CommandInspectCheckpoint, + }, + { + name: "inspect checkpoint transition", + args: joinArgs( + []string{"inspect", "checkpoint-transition"}, + ceremonyTrust, + []string{ + "--previous-checkpoint", "state/checkpoint-0000.json", + "--previous-checkpoint-signature", "state/checkpoint-0000.sig", + "--checkpoint", "state/checkpoint-0001.json", + "--checkpoint-signature", "state/checkpoint-0001.sig", + }, + ), + command: CommandInspectCheckpointTransition, + }, } for _, test := range tests { @@ -626,7 +655,7 @@ func TestParseInvocationRejectsStreamsURLsAndForce(t *testing.T) { func TestReleaseSignRequiresPairedIndependentAudits(t *testing.T) { t.Parallel() - base := []string{ + base := joinArgs([]string{ "release", "sign", "--ceremony", "ceremony.json", "--ceremony-signature", "ceremony.sig", @@ -639,17 +668,15 @@ func TestReleaseSignRequiresPairedIndependentAudits(t *testing.T) { "--signature-key-id", "release-2026", "--released-at", "2026-07-28T12:00:00Z", "--release-dir", "release", + }, cliTestReplayFlags) + if _, err := parseInvocation(base); err != nil { + t.Fatalf("zero audit flags must be parsed before the signed policy is loaded: %v", err) } tests := []struct { name string args []string want string }{ - { - name: "zero audits", - args: append([]string(nil), base...), - want: "at least once", - }, { name: "mismatched signatures", args: append(append([]string(nil), base...), @@ -960,6 +987,18 @@ func TestDiagnosticRedactionRecognizesInspectionAndReceiptCommands(t *testing.T) commandIndex: 0, valueIndex: 3, }, + { + name: "checkpoint inspection", + args: []string{"inspect", "checkpoint", "--checkpoint", "checkpoint.json"}, + commandIndex: 0, + valueIndex: 3, + }, + { + name: "checkpoint transition inspection", + args: []string{"inspect", "checkpoint-transition", "--checkpoint", "checkpoint.json"}, + commandIndex: 0, + valueIndex: 3, + }, { name: "public witness receipt", args: []string{"ops", "prepare-public-witness-receipt", "--publication-location", "https://private.example/closure"}, diff --git a/cmd/mpc-ceremony/decision_test.go b/cmd/mpc-ceremony/decision_test.go index 7735378a..97a1be1c 100644 --- a/cmd/mpc-ceremony/decision_test.go +++ b/cmd/mpc-ceremony/decision_test.go @@ -288,8 +288,9 @@ func decisionSignFixture(t *testing.T) (mpcceremony.CeremonyDefinition, []byte, } } decision, err := mpcceremony.NewProductionDecision(mpcceremony.ProductionDecision{ - CeremonyID: definition.CeremonyID, - Release: release, + CeremonyID: definition.CeremonyID, + AssurancePolicy: definition.AssurancePolicy, + Release: release, SourceRelease: mpcceremony.SourceReleaseEvidence{ SourceCommit: definition.Software.SourceCommit, SignedTag: "v1.0.0-mainnet", SignatureFormat: "openpgp-primary-key-v4", SignerFingerprintHex: strings.Repeat("ab", 20), @@ -396,9 +397,14 @@ func decisionReleaseArtifacts() []mpcceremony.LocatedArtifactRef { } func decisionDraft(decision mpcceremony.ProductionDecision) mpcceremony.ProductionDecisionDraft { + schema := mpcceremony.ProductionDecisionDraftSchema + if decision.Schema == mpcceremony.ProductionDecisionSchemaV1 { + schema = mpcceremony.ProductionDecisionDraftSchemaV1 + } return mpcceremony.ProductionDecisionDraft{ - Schema: mpcceremony.ProductionDecisionDraftSchema, - CeremonyID: decision.CeremonyID, + Schema: schema, + CeremonyID: decision.CeremonyID, + AssurancePolicy: decision.AssurancePolicy, Release: mpcceremony.SignedReleaseEvidenceDraft{ CandidateID: decision.Release.CandidateID, Manifest: decision.Release.Manifest, diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 1ed31784..29a68e79 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -107,6 +107,22 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeInspectParticipant(invocation.Options.(InspectParticipantOptions)) case CommandInspectEnrollment: return executeInspectEnrollment(invocation.Options.(InspectEnrollmentOptions)) + case CommandInspectCheckpoint: + return executeInspectCheckpoint(invocation.Options.(InspectCheckpointOptions)) + case CommandInspectCheckpointTransition: + return executeInspectCheckpointTransition(invocation.Options.(InspectCheckpointTransitionOptions)) + case CommandInspectSubmission: + return executeInspectSubmission(invocation.Options.(InspectSubmissionOptions)) + case CommandInspectSubmissionAcknowledgement: + return executeInspectSubmissionAcknowledgement(invocation.Options.(InspectSubmissionAcknowledgementOptions)) + case CommandCheckpointPrepare: + return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) + case CommandCheckpointSign: + return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) + case CommandCheckpointVerify: + return executeCheckpointVerify(invocation.Options.(CheckpointVerifyOptions)) + case CommandCheckpointVerifyStored: + return executeCheckpointVerifyStored(invocation.Options.(CheckpointVerifyStoredOptions)) default: return CommandResult{}, fmt.Errorf("%w: %s", errExecutorNotWired, invocation.Command) } @@ -121,6 +137,13 @@ func executeInit(options InitOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } + assurancePolicy, err := policy.ResolvedAssurancePolicy(options.Mode) + if err != nil { + return CommandResult{}, fmt.Errorf("assurance policy: %w", err) + } + if err := assurancePolicy.Validate(options.Mode, len(participants.Auditors)); err != nil { + return CommandResult{}, fmt.Errorf("assurance policy: %w", err) + } if participants.Coordinator.KeyID != options.CoordinatorKeyID { return CommandResult{}, fmt.Errorf( "--coordinator-key-id %q does not match participants coordinator key id %q", @@ -172,6 +195,7 @@ func executeInit(options InitOptions) (CommandResult, error) { Phase1Policy: policy.Phase1Policy, Phase2Policy: policy.Phase2Policy, BeaconPolicy: policy.BeaconPolicy, + AssurancePolicy: assurancePolicy, }, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, }) @@ -649,6 +673,14 @@ func executeReleaseSign(options ReleaseSignOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } + compiled, err := compileCircuitForCeremony(trust) + if err != nil { + return CommandResult{}, err + } + replayEvidence, err := replayPaths(trust, options.Replay) + if err != nil { + return CommandResult{}, err + } result, err := mpcceremony.SignRelease(mpcceremony.SignReleaseOptions{ DefinitionPath: options.CeremonyPath, DefinitionSignaturePath: options.CeremonySignaturePath, @@ -662,6 +694,8 @@ func executeReleaseSign(options ReleaseSignOptions) (CommandResult, error) { ReleaseSigningKey: options.ReleaseSigningKey, SignatureKeyID: options.SignatureKeyID, ReleasedAt: releasedAt, + Replay: &replayEvidence, + Circuit: compiled, }) if err != nil { return CommandResult{}, err diff --git a/cmd/mpc-ceremony/inspect.go b/cmd/mpc-ceremony/inspect.go index 7c1c311a..6812e502 100644 --- a/cmd/mpc-ceremony/inspect.go +++ b/cmd/mpc-ceremony/inspect.go @@ -4,16 +4,21 @@ package main import ( + "errors" "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" + 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" + checkpointInspectionSchema = "proof-tool-mpc-checkpoint-inspection-v1" + checkpointTransitionInspectionSchema = "proof-tool-mpc-checkpoint-transition-inspection-v1" + submissionInspectionSchema = "proof-tool-mpc-submission-inspection-v1" + submissionAcknowledgementInspectionSchema = "proof-tool-mpc-submission-acknowledgement-inspection-v1" ) func executeInspectDefinition(options InspectDefinitionOptions) (CommandResult, error) { @@ -122,6 +127,268 @@ func executeInspectEnrollment(options InspectEnrollmentOptions) (CommandResult, }, nil } +func executeInspectCheckpoint(options InspectCheckpointOptions) (CommandResult, error) { + trusted, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + checkpoint, checkpointBytes, _, err := loadInspectionCheckpoint( + trusted.Definition, + definitionBytes, + definitionSignatureBytes, + options.CheckpointPath, + options.CheckpointSignaturePath, + ) + if err != nil { + return CommandResult{}, err + } + inspection := inspectCheckpoint(checkpoint, checkpointBytes) + return CommandResult{ + CeremonyID: checkpoint.CeremonyID, + Summary: fmt.Sprintf("authenticated checkpoint %d; referenced protocol artifacts were not replayed", checkpoint.Sequence), + CheckpointInspection: &inspection, + }, nil +} + +func executeInspectCheckpointTransition(options InspectCheckpointTransitionOptions) (CommandResult, error) { + trusted, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + previous, previousBytes, previousSignatureBytes, err := loadInspectionCheckpoint( + trusted.Definition, + definitionBytes, + definitionSignatureBytes, + options.PreviousCheckpointPath, + options.PreviousCheckpointSignaturePath, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("previous checkpoint: %w", err) + } + next, nextBytes, _, err := loadInspectionCheckpoint( + trusted.Definition, + definitionBytes, + definitionSignatureBytes, + options.CheckpointPath, + options.CheckpointSignaturePath, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("next checkpoint: %w", err) + } + if err := mpcceremony.ValidateCheckpointTransition(previous, next); err != nil { + return CommandResult{}, fmt.Errorf("checkpoint transition: %w", err) + } + previousSignatureDigest := mpcceremony.NewDigest(previousSignatureBytes) + if next.PreviousCheckpoint == nil || next.PreviousCheckpoint.Signature.Digest != previousSignatureDigest { + return CommandResult{}, fmt.Errorf("checkpoint transition: next checkpoint does not bind the exact previous checkpoint signature") + } + checkpointInspection := inspectCheckpoint(next, nextBytes) + inspection := CheckpointTransitionInspection{ + Schema: checkpointTransitionInspectionSchema, + CeremonyID: next.CeremonyID, + PreviousSequence: previous.Sequence, + Sequence: next.Sequence, + PreviousCheckpointDigest: mpcceremony.NewDigest(previousBytes), + PreviousSignatureDigest: previousSignatureDigest, + CheckpointDigest: mpcceremony.NewDigest(nextBytes), + Transition: next.Transition, + Checkpoint: checkpointInspection, + } + return CommandResult{ + CeremonyID: next.CeremonyID, + Summary: fmt.Sprintf("authenticated legal checkpoint transition %d to %d; referenced protocol artifacts were not replayed", previous.Sequence, next.Sequence), + CheckpointTransitionInspection: &inspection, + }, nil +} + +func executeInspectSubmission(options InspectSubmissionOptions) (CommandResult, error) { + trusted, checkpoint, slot, envelope, envelopeBytes, envelopeSignatureBytes, manifestBytes, err := loadInspectionSubmission(options) + if err != nil { + return CommandResult{}, err + } + _ = checkpoint + inspection := inspectSubmission(envelope, envelopeBytes, envelopeSignatureBytes, manifestBytes) + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: fmt.Sprintf("authenticated %s submission for phase1 index %d and its exact allocated slot", slot.Kind, slot.Index), + SubmissionInspection: &inspection, + }, nil +} + +func executeInspectSubmissionAcknowledgement(options InspectSubmissionAcknowledgementOptions) (CommandResult, error) { + trusted, checkpoint, slot, envelope, envelopeBytes, envelopeSignatureBytes, manifestBytes, err := loadInspectionSubmission(options.InspectSubmissionOptions) + if err != nil { + return CommandResult{}, err + } + acknowledgementBytes, err := readRegularOperationalFile(options.AcknowledgementPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + acknowledgementSignatureBytes, err := readRegularOperationalFile(options.AcknowledgementSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + acknowledgement, err := mpcceremony.VerifySignedSubmissionAcknowledgement( + trusted.Definition, checkpoint, slot, + envelope.ManifestKey+".envelope.json", envelope.ManifestKey+".envelope.sig", + envelopeBytes, envelopeSignatureBytes, envelope.ManifestKey, manifestBytes, + acknowledgementBytes, acknowledgementSignatureBytes, + ) + if err != nil { + // The acknowledgement commits the actual logical envelope names. Retry + // with those authenticated names rather than guessing from local paths. + var parsed mpcceremony.SubmissionAcknowledgementV1 + if parseErr := mpcceremony.UnmarshalCanonical(acknowledgementBytes, &parsed); parseErr != nil { + return CommandResult{}, err + } + acknowledgement, err = mpcceremony.VerifySignedSubmissionAcknowledgement( + trusted.Definition, checkpoint, slot, + parsed.Envelope.Record.Name, parsed.Envelope.Signature.Name, + envelopeBytes, envelopeSignatureBytes, envelope.ManifestKey, manifestBytes, + acknowledgementBytes, acknowledgementSignatureBytes, + ) + if err != nil { + return CommandResult{}, err + } + } + submission := inspectSubmission(envelope, envelopeBytes, envelopeSignatureBytes, manifestBytes) + inspection := SubmissionAcknowledgementInspection{ + Schema: submissionAcknowledgementInspectionSchema, Submission: submission, + CoordinatorID: acknowledgement.CoordinatorID, CoordinatorKeyID: acknowledgement.CoordinatorKeyID, + Result: acknowledgement.Result, ReasonCode: acknowledgement.ReasonCode, + AcknowledgementDigest: mpcceremony.NewDigest(acknowledgementBytes), + AcknowledgementSignatureDigest: mpcceremony.NewDigest(acknowledgementSignatureBytes), + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: fmt.Sprintf("authenticated coordinator %s acknowledgement for exact submission attempt", acknowledgement.Result), + SubmissionAcknowledgementInspection: &inspection, + }, nil +} + +func loadInspectionSubmission(options InspectSubmissionOptions) (*mpcceremony.TrustedCeremony, mpcceremony.Checkpoint, mpcceremony.CheckpointSubmissionSlot, mpcceremony.SubmissionEnvelopeV1, []byte, []byte, []byte, error) { + trusted, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return nil, mpcceremony.Checkpoint{}, mpcceremony.CheckpointSubmissionSlot{}, mpcceremony.SubmissionEnvelopeV1{}, nil, nil, nil, err + } + checkpoint, _, _, err := loadInspectionCheckpoint(trusted.Definition, definitionBytes, definitionSignatureBytes, options.CheckpointPath, options.CheckpointSignaturePath) + if err != nil { + return nil, mpcceremony.Checkpoint{}, mpcceremony.CheckpointSubmissionSlot{}, mpcceremony.SubmissionEnvelopeV1{}, nil, nil, nil, err + } + slot := mpcceremony.CheckpointSubmissionSlot{ + Kind: mpcceremony.CheckpointSubmissionKind(options.Kind), Phase: mpcceremony.Phase(options.Phase), Index: uint8(options.Index), + IdentityID: options.SubmitterID, AttemptID: options.AttemptID, + } + found := false + for _, candidate := range checkpoint.Submissions { + if candidate.Kind == slot.Kind && candidate.Phase == slot.Phase && candidate.Index == slot.Index && + candidate.IdentityID == slot.IdentityID && candidate.AttemptID == slot.AttemptID { + slot, found = candidate, true + break + } + } + if !found { + return nil, mpcceremony.Checkpoint{}, mpcceremony.CheckpointSubmissionSlot{}, mpcceremony.SubmissionEnvelopeV1{}, nil, nil, nil, errors.New("submission scope is not an exact checkpoint slot") + } + envelopeBytes, err := readRegularOperationalFile(options.EnvelopePath, maxOperationalRecordBytes) + if err != nil { + return nil, mpcceremony.Checkpoint{}, slot, mpcceremony.SubmissionEnvelopeV1{}, nil, nil, nil, err + } + envelopeSignatureBytes, err := readRegularOperationalFile(options.EnvelopeSignaturePath, 4096) + if err != nil { + return nil, mpcceremony.Checkpoint{}, slot, mpcceremony.SubmissionEnvelopeV1{}, nil, nil, nil, err + } + envelope, err := mpcceremony.VerifySignedSubmissionEnvelope(trusted.Definition, checkpoint, slot, envelopeBytes, envelopeSignatureBytes) + if err != nil { + return nil, mpcceremony.Checkpoint{}, slot, mpcceremony.SubmissionEnvelopeV1{}, nil, nil, nil, err + } + manifestBytes, err := readRegularOperationalFile(options.ManifestPath, maxOperationalRecordBytes) + if err != nil { + return nil, mpcceremony.Checkpoint{}, slot, mpcceremony.SubmissionEnvelopeV1{}, nil, nil, nil, err + } + return trusted, checkpoint, slot, envelope, envelopeBytes, envelopeSignatureBytes, manifestBytes, nil +} + +func inspectSubmission(envelope mpcceremony.SubmissionEnvelopeV1, envelopeBytes, envelopeSignatureBytes, manifestBytes []byte) SubmissionInspection { + return SubmissionInspection{ + Schema: submissionInspectionSchema, CeremonyID: envelope.CeremonyID, Workflow: envelope.Workflow, + RelayReleaseID: envelope.RelayReleaseID, SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, + SubmitterRole: envelope.SubmitterRole, Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, ParentHeadID: envelope.ParentHeadID, + AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Payloads: append([]mpcceremony.ArtifactRef(nil), envelope.Payloads...), + EnvelopeDigest: mpcceremony.NewDigest(envelopeBytes), EnvelopeSignatureDigest: mpcceremony.NewDigest(envelopeSignatureBytes), + ManifestDigest: mpcceremony.NewDigest(manifestBytes), + } +} + +func loadExactInspectionCeremony(options InspectDefinitionOptions) (*mpcceremony.TrustedCeremony, []byte, []byte, error) { + trusted, err := loadInspectionCeremony(options) + if err != nil { + return nil, nil, nil, err + } + definitionBytes, err := readRegularOperationalFile(options.CeremonyPath, maxOperationalRecordBytes) + if err != nil { + return nil, nil, nil, err + } + definitionSignatureBytes, err := readRegularOperationalFile(options.CeremonySignaturePath, 4096) + if err != nil { + return nil, nil, nil, err + } + return trusted, definitionBytes, definitionSignatureBytes, nil +} + +func loadInspectionCheckpoint( + definition mpcceremony.CeremonyDefinition, + definitionBytes, definitionSignatureBytes []byte, + checkpointPath, checkpointSignaturePath string, +) (mpcceremony.Checkpoint, []byte, []byte, error) { + checkpointBytes, err := readRegularOperationalFile(checkpointPath, maxOperationalRecordBytes) + if err != nil { + return mpcceremony.Checkpoint{}, nil, nil, err + } + checkpointSignatureBytes, err := readRegularOperationalFile(checkpointSignaturePath, 4096) + if err != nil { + return mpcceremony.Checkpoint{}, nil, nil, err + } + checkpoint, err := mpcceremony.VerifySignedCheckpoint( + definition, + definitionBytes, + definitionSignatureBytes, + checkpointBytes, + checkpointSignatureBytes, + ) + if err != nil { + return mpcceremony.Checkpoint{}, nil, nil, err + } + return checkpoint, checkpointBytes, checkpointSignatureBytes, nil +} + +func inspectCheckpoint(checkpoint mpcceremony.Checkpoint, checkpointBytes []byte) CheckpointInspection { + return CheckpointInspection{ + Schema: checkpointInspectionSchema, + CeremonyID: checkpoint.CeremonyID, + Workflow: checkpoint.Workflow, + RelayReleaseID: checkpoint.RelayReleaseID, + Sequence: checkpoint.Sequence, + Digest: mpcceremony.NewDigest(checkpointBytes), + Definition: checkpoint.Definition, + PreviousCheckpoint: checkpoint.PreviousCheckpoint, + Transition: checkpoint.Transition, + Phase1: checkpoint.Phase1, + Phase1Closure: checkpoint.Phase1Closure, + Phase1Beacon: checkpoint.Phase1Beacon, + Phase1Seal: checkpoint.Phase1Seal, + Phase2: checkpoint.Phase2, + Phase2Closure: checkpoint.Phase2Closure, + Phase2Beacon: checkpoint.Phase2Beacon, + FinalCandidate: checkpoint.FinalCandidate, + FinalRelease: checkpoint.FinalRelease, + Submissions: append([]mpcceremony.CheckpointSubmissionSlot(nil), checkpoint.Submissions...), + AcceptedArtifacts: append([]mpcceremony.ArtifactRef(nil), checkpoint.AcceptedArtifacts...), + } +} + func cloneUint8Pointer(value *uint8) *uint8 { if value == nil { return nil diff --git a/cmd/mpc-ceremony/inspect_test.go b/cmd/mpc-ceremony/inspect_test.go index 47f2472f..f7b16e5d 100644 --- a/cmd/mpc-ceremony/inspect_test.go +++ b/cmd/mpc-ceremony/inspect_test.go @@ -146,6 +146,9 @@ func TestInspectParticipantMatchesRosterPositionsWithoutExposingPrivateKey(t *te root := t.TempDir() definition, _, coordinatorKey := decisionSignFixture(t) definition.Mode = mpcceremony.ModeRehearsal + assurance := *definition.AssurancePolicy + assurance.ExternalSecurityAuditSignoffs = 0 + definition.AssurancePolicy = &assurance definition.Phase2Policy.Participants = []string{"participant-01", "participant-03"} definition.Phase2Policy.Minimum = 2 var err error diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index cdcaf398..92227041 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -197,6 +197,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--accepted-at", "--allowed-binary", "--contributed-at", + "--disable-optional-assurance", } flagPattern := regexp.MustCompile(`--[a-z0-9-]+`) seenSet := make(map[string]struct{}) @@ -231,6 +232,12 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandInspectChain, Options: InspectChainOptions{}}, {Command: CommandInspectParticipant, Options: InspectParticipantOptions{}}, {Command: CommandInspectEnrollment, Options: InspectEnrollmentOptions{}}, + {Command: CommandInspectCheckpoint, Options: InspectCheckpointOptions{}}, + {Command: CommandInspectCheckpointTransition, Options: InspectCheckpointTransitionOptions{}}, + {Command: CommandCheckpointPrepare, Options: CheckpointPrepareOptions{}}, + {Command: CommandCheckpointSign, Options: CheckpointSignOptions{}}, + {Command: CommandCheckpointVerify, Options: CheckpointVerifyOptions{}}, + {Command: CommandCheckpointVerifyStored, Options: CheckpointVerifyStoredOptions{}}, } for _, invocation := range tests { t.Run(string(invocation.Command), func(t *testing.T) { @@ -269,6 +276,12 @@ func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { {"inspect", "chain"}, {"inspect", "participant"}, {"inspect", "enrollment"}, + {"inspect", "checkpoint"}, + {"inspect", "checkpoint-transition"}, + {"checkpoint", "prepare"}, + {"checkpoint", "sign"}, + {"checkpoint", "verify"}, + {"checkpoint", "verify-stored"}, {"ops", "prepare-public-witness-receipt"}, {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, diff --git a/cmd/mpc-ceremony/journey_inspection.go b/cmd/mpc-ceremony/journey_inspection.go index b38dee93..cff4620a 100644 --- a/cmd/mpc-ceremony/journey_inspection.go +++ b/cmd/mpc-ceremony/journey_inspection.go @@ -16,6 +16,9 @@ type DefinitionJourneyInspection struct { RequiredEnrollments []ExpectedEnrollmentInspection `json:"required_enrollments"` MinimumPublicWitnesses int `json:"minimum_public_witnesses"` MinimumMirrorsPerAcceptedHead int `json:"minimum_mirrors_per_accepted_head"` + MinimumPassingCeremonyAudits int `json:"minimum_passing_ceremony_audits"` + MinimumExternalAuditSignoffs int `json:"minimum_external_audit_signoffs"` + BeaconRoundLeadSeconds uint32 `json:"beacon_round_lead_seconds"` ObserverRequirementSource string `json:"observer_requirement_source"` } @@ -44,7 +47,14 @@ type JourneyInspection struct { } func inspectDefinitionJourney(d mpcceremony.CeremonyDefinition) *DefinitionJourneyInspection { - r := &DefinitionJourneyInspection{Schema: "proof-tool-mpc-definition-journey-v1", MinimumPublicWitnesses: 1, MinimumMirrorsPerAcceptedHead: 1, ObserverRequirementSource: "operational-bundle verifier minimum; an agreed witness quorum can require more"} + r := &DefinitionJourneyInspection{Schema: "proof-tool-mpc-definition-journey-v2", MinimumPublicWitnesses: 1, MinimumMirrorsPerAcceptedHead: 1, MinimumPassingCeremonyAudits: 1, MinimumExternalAuditSignoffs: 1, BeaconRoundLeadSeconds: d.BeaconPolicy.MinimumWitnessLeadSeconds, ObserverRequirementSource: "legacy verifier minimums"} + if d.Schema == mpcceremony.DefinitionSchema && d.AssurancePolicy != nil { + r.MinimumPublicWitnesses = int(d.AssurancePolicy.PublicWitnessesPerPhase) + r.MinimumMirrorsPerAcceptedHead = int(d.AssurancePolicy.MirrorsPerAcceptedHead) + r.MinimumPassingCeremonyAudits = int(d.AssurancePolicy.PassingCeremonyAudits) + r.MinimumExternalAuditSignoffs = int(d.AssurancePolicy.ExternalSecurityAuditSignoffs) + r.ObserverRequirementSource = "signed ceremony assurance_policy" + } r.RequiredEnrollments = append(r.RequiredEnrollments, ExpectedEnrollmentInspection{mpcceremony.EnrollmentCoordinator, 1, d.Coordinator}, ExpectedEnrollmentInspection{mpcceremony.EnrollmentReleaseSigner, 1, d.ReleaseSigner}) for n, id := range d.Auditors { r.RequiredEnrollments = append(r.RequiredEnrollments, ExpectedEnrollmentInspection{mpcceremony.EnrollmentAuditor, n + 1, id}) diff --git a/cmd/mpc-ceremony/journey_inspection_test.go b/cmd/mpc-ceremony/journey_inspection_test.go index 60ee7111..03d32405 100644 --- a/cmd/mpc-ceremony/journey_inspection_test.go +++ b/cmd/mpc-ceremony/journey_inspection_test.go @@ -27,11 +27,32 @@ func TestDefinitionJourneyProjectsEveryRequiredEnrollment(t *testing.T) { t.Fatal("incorrect participant assignment") } } - if j.MinimumPublicWitnesses != 1 || j.MinimumMirrorsPerAcceptedHead != 1 || j.ObserverRequirementSource == "" { + if j.MinimumPublicWitnesses != 1 || j.MinimumMirrorsPerAcceptedHead != 1 || j.MinimumPassingCeremonyAudits != 1 || j.MinimumExternalAuditSignoffs != 1 || j.ObserverRequirementSource != "signed ceremony assurance_policy" { t.Fatal("operational verifier minimums omitted") } + if j.BeaconRoundLeadSeconds != d.BeaconPolicy.MinimumWitnessLeadSeconds { + t.Fatal("signed beacon lead omitted") + } d.Auditors[0].DisplayName = "changed" if j.RequiredEnrollments[2].Identity.DisplayName == "changed" { t.Fatal("projection aliases mutable roster") } } + +func TestDefinitionJourneyProjectsDisabledControlsFromSignedPolicy(t *testing.T) { + d, _, _ := decisionSignFixture(t) + d.CeremonyID = "" + d.Auditors = nil + d.AssurancePolicy = &mpcceremony.AssurancePolicy{} + d, err := mpcceremony.FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + j := inspectDefinitionJourney(d) + if j.MinimumPublicWitnesses != 0 || j.MinimumMirrorsPerAcceptedHead != 0 || j.MinimumPassingCeremonyAudits != 0 || j.MinimumExternalAuditSignoffs != 0 { + t.Fatalf("disabled policy projection = %#v", j) + } + if len(j.RequiredEnrollments) != 2+len(d.Roster) { + t.Fatal("disabled auditor enrollment remained required") + } +} diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 7798bfcf..e7d10be4 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -252,7 +252,7 @@ func identifyCLICommandArguments(args []string) map[int]struct{} { command: topLevel := map[string]struct{}{ - "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, + "audit": {}, "checkpoint": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "rehearsal": {}, "release": {}, "replay": {}, } @@ -270,9 +270,10 @@ command: "attest-erasure": {}, "beacon": {}, "close": {}, "contribute": {}, "help": {}, "init": {}, "verify": {}, }, - "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, + "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, + "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}}, "inspect": { - "chain": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, + "chain": {}, "checkpoint": {}, "checkpoint-transition": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, }, "ops": { "export-signing": {}, "help": {}, "import-signature": {}, "sign": {}, "prepare-enrollment": {}, "prepare-handoff": {}, "prepare-receipt": {}, diff --git a/cmd/mpc-ceremony/ops_bundle.go b/cmd/mpc-ceremony/ops_bundle.go index a34b44ab..06d166b6 100644 --- a/cmd/mpc-ceremony/ops_bundle.go +++ b/cmd/mpc-ceremony/ops_bundle.go @@ -14,7 +14,6 @@ import ( type OpsPrepareBundleOptions struct { CeremonyPath, CeremonySignaturePath, CoordinatorPublicKeyFile string EvidenceRoot, OutDir string - WitnessQuorum uint } func parseOpsPrepareBundle(args []string) (OpsPrepareBundleOptions, error) { @@ -23,13 +22,9 @@ func parseOpsPrepareBundle(args []string) (OpsPrepareBundleOptions, error) { addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) f.StringVar(&o.EvidenceRoot, "evidence-root", "", "public-only evidence directory; never a keys or credentials directory") f.StringVar(&o.OutDir, "out-dir", "", "evidence-root/operational; existing evidence is preserved, bundle outputs must be fresh") - f.UintVar(&o.WitnessQuorum, "witness-quorum", 1, "agreed minimum public witnesses per phase (1-32)") if err := parseFlags(f, args); err != nil { return o, err } - if o.WitnessQuorum < 1 || o.WitnessQuorum > 32 { - return o, errors.New("witness quorum must be between 1 and 32") - } return o, requireValues(pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--evidence-root", o.EvidenceRoot), pathValue("--out-dir", o.OutDir)) } @@ -38,19 +33,10 @@ func executeOpsPrepareBundle(o OpsPrepareBundleOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - if o.WitnessQuorum < 1 || o.WitnessQuorum > 32 { - return CommandResult{}, errors.New("witness quorum must be between 1 and 32") - } prepared, err := mpcceremony.PrepareOperationalEvidence(trusted.Definition, o.EvidenceRoot, time.Now().UTC().Format(time.RFC3339Nano)) if err != nil { return CommandResult{}, err } - for index, phase := range []*mpcceremony.PhaseOperationalEvidence{&prepared.Bundle.Phase1, &prepared.Bundle.Phase2} { - phase.PublicWitnessQuorum = uint8(o.WitnessQuorum) - if o.WitnessQuorum > 1 && len(phase.PublicWitnessReceipts) < int(o.WitnessQuorum) { - prepared.Missing = append(prepared.Missing, fmt.Sprintf("phase%d: agreed witness quorum is %d, found %d records", index+1, o.WitnessQuorum, len(phase.PublicWitnessReceipts))) - } - } if len(prepared.Missing) > 0 { return CommandResult{}, fmt.Errorf("evidence preparation incomplete (discovery is not verification):\n- %s\nCollect the original public records and signatures from their owners, retaining referenced relative paths, then retry. Do not invent or backdate evidence", strings.Join(prepared.Missing, "\n- ")) } diff --git a/cmd/mpc-ceremony/ops_bundle_test.go b/cmd/mpc-ceremony/ops_bundle_test.go index 2e8ac657..4afd893c 100644 --- a/cmd/mpc-ceremony/ops_bundle_test.go +++ b/cmd/mpc-ceremony/ops_bundle_test.go @@ -37,6 +37,9 @@ func TestPrepareBundleCommandHelpAndRequiredInputs(t *testing.T) { if _, err := parseInvocation([]string{"ops", "prepare-bundle", "--help"}); err == nil { t.Fatal("expected help request") } + if _, err := parseOpsPrepareBundle([]string{"--witness-quorum", "1"}); err == nil { + t.Fatal("operator-controlled witness quorum was accepted") + } } func TestBundleExportPreservesExistingEvidence(t *testing.T) { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 6c194495..c2feac32 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -99,6 +99,8 @@ func parseInvocation(args []string) (Invocation, error) { return parseOps(invocation, rest[1:]) case "decision": return parseDecision(invocation, rest[1:]) + case "checkpoint": + return parseCheckpoint(invocation, rest[1:]) default: return Invocation{}, &usageError{ message: fmt.Sprintf("unknown command %q", rest[0]), @@ -174,6 +176,7 @@ func parseRehearsalInit(args []string) (RehearsalInitOptions, error) { fs.StringVar(&options.CreatedAt, "created-at", "", "ceremony creation timestamp in RFC3339") fs.StringVar(&options.OutDir, "out-dir", "", "fresh rehearsal work directory") fs.Uint64Var(&options.BeaconLeadSeconds, "beacon-lead-seconds", rehearsalBeaconLeadSeconds, "non-production witness window for this rehearsal") + fs.BoolVar(&options.DisableOptionalAssurance, "disable-optional-assurance", false, "sign explicit zero witness, mirror, and ceremony-audit requirements (rehearsal only)") fs.Var(&allowedBinaries, "allowed-binary", "additional exact mpc-ceremony binary to sign into the platform allowlist (repeatable)") if err := parseFlags(fs, args); err != nil { return options, err @@ -220,6 +223,22 @@ func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, e options, err := parseInspectEnrollment(args[1:]) invocation.Command, invocation.Options = CommandInspectEnrollment, options return invocation, wrapCommandError(err, "inspect", "enrollment") + case "checkpoint": + options, err := parseInspectCheckpoint(args[1:]) + invocation.Command, invocation.Options = CommandInspectCheckpoint, options + return invocation, wrapCommandError(err, "inspect", "checkpoint") + case "checkpoint-transition": + options, err := parseInspectCheckpointTransition(args[1:]) + invocation.Command, invocation.Options = CommandInspectCheckpointTransition, options + return invocation, wrapCommandError(err, "inspect", "checkpoint-transition") + case "submission": + options, err := parseInspectSubmission(args[1:]) + invocation.Command, invocation.Options = CommandInspectSubmission, options + return invocation, wrapCommandError(err, "inspect", "submission") + case "submission-acknowledgement": + options, err := parseInspectSubmissionAcknowledgement(args[1:]) + invocation.Command, invocation.Options = CommandInspectSubmissionAcknowledgement, options + return invocation, wrapCommandError(err, "inspect", "submission-acknowledgement") default: return Invocation{}, &usageError{ message: fmt.Sprintf("unknown inspect command %q", args[0]), @@ -228,6 +247,98 @@ func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, e } } +func addSubmissionInspectionFlags(fs *flag.FlagSet, options *InspectSubmissionOptions) { + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "signed checkpoint containing the allocated slot") + fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "detached checkpoint signature") + fs.StringVar(&options.Kind, "kind", "", "submission kind: receipt or candidate") + fs.StringVar(&options.Phase, "phase", "", "submission phase") + fs.UintVar(&options.Index, "index", 0, "one-based contribution index") + fs.StringVar(&options.SubmitterID, "submitter-id", "", "assigned participant identity") + fs.StringVar(&options.AttemptID, "attempt-id", "", "preallocated submission attempt") + fs.StringVar(&options.EnvelopePath, "envelope", "", "canonical participant submission envelope") + fs.StringVar(&options.EnvelopeSignaturePath, "envelope-signature", "", "detached participant envelope signature") + fs.StringVar(&options.ManifestPath, "manifest", "", "exact transport manifest") +} + +func validateSubmissionInspectionOptions(options InspectSubmissionOptions) error { + if options.Index == 0 || options.Index > mpcceremony.MaxParticipants { + return fmt.Errorf("--index must be between 1 and %d", mpcceremony.MaxParticipants) + } + return requireValues( + pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), pathValue("--checkpoint", options.CheckpointPath), + pathValue("--checkpoint-signature", options.CheckpointSignaturePath), value("--kind", options.Kind), value("--phase", options.Phase), + value("--submitter-id", options.SubmitterID), value("--attempt-id", options.AttemptID), + pathValue("--envelope", options.EnvelopePath), pathValue("--envelope-signature", options.EnvelopeSignaturePath), pathValue("--manifest", options.ManifestPath), + ) +} + +func parseInspectSubmission(args []string) (InspectSubmissionOptions, error) { + var options InspectSubmissionOptions + fs := commandFlagSet("inspect submission") + addSubmissionInspectionFlags(fs, &options) + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, validateSubmissionInspectionOptions(options) +} + +func parseInspectSubmissionAcknowledgement(args []string) (InspectSubmissionAcknowledgementOptions, error) { + var options InspectSubmissionAcknowledgementOptions + fs := commandFlagSet("inspect submission-acknowledgement") + addSubmissionInspectionFlags(fs, &options.InspectSubmissionOptions) + fs.StringVar(&options.AcknowledgementPath, "acknowledgement", "", "canonical coordinator acknowledgement") + fs.StringVar(&options.AcknowledgementSignaturePath, "acknowledgement-signature", "", "detached coordinator acknowledgement signature") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := validateSubmissionInspectionOptions(options.InspectSubmissionOptions); err != nil { + return options, err + } + return options, requireValues(pathValue("--acknowledgement", options.AcknowledgementPath), pathValue("--acknowledgement-signature", options.AcknowledgementSignaturePath)) +} + +func parseInspectCheckpoint(args []string) (InspectCheckpointOptions, error) { + var options InspectCheckpointOptions + fs := commandFlagSet("inspect checkpoint") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "canonical signed checkpoint record") + fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "detached checkpoint 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("--checkpoint", options.CheckpointPath), + pathValue("--checkpoint-signature", options.CheckpointSignaturePath), + ) +} + +func parseInspectCheckpointTransition(args []string) (InspectCheckpointTransitionOptions, error) { + var options InspectCheckpointTransitionOptions + fs := commandFlagSet("inspect checkpoint-transition") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.PreviousCheckpointPath, "previous-checkpoint", "", "canonical previous checkpoint record") + fs.StringVar(&options.PreviousCheckpointSignaturePath, "previous-checkpoint-signature", "", "detached previous checkpoint signature") + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "canonical next checkpoint record") + fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "detached next checkpoint 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("--previous-checkpoint", options.PreviousCheckpointPath), + pathValue("--previous-checkpoint-signature", options.PreviousCheckpointSignaturePath), + pathValue("--checkpoint", options.CheckpointPath), + pathValue("--checkpoint-signature", options.CheckpointSignaturePath), + ) +} + func parseInspectParticipant(args []string) (InspectParticipantOptions, error) { var options InspectParticipantOptions fs := commandFlagSet("inspect participant") @@ -1131,6 +1242,7 @@ func parseReleaseSign(args []string) (ReleaseSignOptions, error) { fs.StringVar(&options.SignatureKeyID, "signature-key-id", "", "release signing key identifier") fs.StringVar(&options.ReleasedAt, "released-at", "", "release publication timestamp in RFC3339 UTC") fs.StringVar(&options.ReleaseDir, "release-dir", "", "fresh release bundle directory distinct from the candidate") + addReplayFlags(fs, &options.Replay) if err := parseFlags(fs, args); err != nil { return options, err } @@ -1154,7 +1266,7 @@ func parseReleaseSign(args []string) (ReleaseSignOptions, error) { if err := validateAuditArtifacts(options.AuditReportPaths, options.AuditSignaturePaths); err != nil { return options, err } - return options, nil + return options, validateReplayOptions(options.Replay) } func parseReleaseVerify(args []string) (ReleaseVerifyOptions, error) { @@ -1219,9 +1331,6 @@ func validateReplayOptions(replay ReplayOptions) error { } func validateAuditArtifacts(reports, signatures []string) error { - if len(reports) < 1 { - return errors.New("--audit-report must be supplied at least once") - } 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) } diff --git a/cmd/mpc-ceremony/rehearsal.go b/cmd/mpc-ceremony/rehearsal.go index 76f1d409..33e8f97e 100644 --- a/cmd/mpc-ceremony/rehearsal.go +++ b/cmd/mpc-ceremony/rehearsal.go @@ -18,10 +18,11 @@ const ( ) func executeRehearsalInit(options RehearsalInitOptions) (result CommandResult, err error) { - if err := mpcrehearsal.Generate( + if err := mpcrehearsal.GenerateWithAssurance( options.OutDir, rehearsalParticipantCount, uint32(options.BeaconLeadSeconds), + !options.DisableOptionalAssurance, ); err != nil { return CommandResult{}, err } diff --git a/cmd/mpc-ceremony/rehearsal_test.go b/cmd/mpc-ceremony/rehearsal_test.go index 1bf444df..60804db5 100644 --- a/cmd/mpc-ceremony/rehearsal_test.go +++ b/cmd/mpc-ceremony/rehearsal_test.go @@ -44,6 +44,19 @@ func TestParseRehearsalInitIsNarrowAndExplicit(t *testing.T) { t.Fatalf("custom beacon lead = %d, want 12", got) } + disabled, err := parseInvocation([]string{ + "rehearsal", "init", + "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", + "--disable-optional-assurance", + }) + if err != nil { + t.Fatal(err) + } + if !disabled.Options.(RehearsalInitOptions).DisableOptionalAssurance { + t.Fatal("disable-optional-assurance flag was not preserved") + } + 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"}, diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index b2be5fba..b82e65ca 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -15,45 +15,53 @@ const commandResultSchema = "proof-tool-mpc-command-result-v1" type Command string const ( - CommandInit Command = "init" - CommandIdentityGenerate Command = "identity generate" - CommandRehearsalInit Command = "rehearsal 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" - CommandOpsPrepareCustody Command = "ops prepare-custody" - CommandFinalizePrepare Command = "finalize prepare" - CommandRehearsalEvidence Command = "finalize rehearsal-evidence" - CommandFinalizeComplete Command = "finalize complete" - CommandAudit Command = "audit" - CommandReplay Command = "replay" - 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" - CommandOpsPrepareEnrollment Command = "ops prepare-enrollment" - CommandOpsPrepareBundle Command = "ops prepare-bundle" - CommandOpsSign Command = "ops sign" - 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" + CommandInit Command = "init" + CommandIdentityGenerate Command = "identity generate" + CommandRehearsalInit Command = "rehearsal 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" + CommandOpsPrepareCustody Command = "ops prepare-custody" + CommandFinalizePrepare Command = "finalize prepare" + CommandRehearsalEvidence Command = "finalize rehearsal-evidence" + CommandFinalizeComplete Command = "finalize complete" + CommandAudit Command = "audit" + CommandReplay Command = "replay" + 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" + CommandOpsPrepareEnrollment Command = "ops prepare-enrollment" + CommandOpsPrepareBundle Command = "ops prepare-bundle" + CommandOpsSign Command = "ops sign" + 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" + CommandInspectCheckpoint Command = "inspect checkpoint" + CommandInspectCheckpointTransition Command = "inspect checkpoint-transition" + CommandInspectSubmission Command = "inspect submission" + CommandInspectSubmissionAcknowledgement Command = "inspect submission-acknowledgement" + CommandCheckpointPrepare Command = "checkpoint prepare" + CommandCheckpointSign Command = "checkpoint sign" + CommandCheckpointVerify Command = "checkpoint verify" + CommandCheckpointVerifyStored Command = "checkpoint verify-stored" ) type GlobalOptions struct { @@ -88,10 +96,11 @@ type InitOptions struct { } type RehearsalInitOptions struct { - CreatedAt string - OutDir string - BeaconLeadSeconds uint64 - AllowedBinaryPaths []string + CreatedAt string + OutDir string + BeaconLeadSeconds uint64 + AllowedBinaryPaths []string + DisableOptionalAssurance bool } type ContributeOptions struct { @@ -232,6 +241,7 @@ type ReleaseSignOptions struct { SignatureKeyID string ReleasedAt string ReleaseDir string + Replay ReplayOptions } type ReleaseVerifyOptions struct { @@ -326,6 +336,89 @@ type InspectEnrollmentOptions struct { EnrollmentSignaturePath string } +type InspectCheckpointOptions struct { + InspectDefinitionOptions + CheckpointPath string + CheckpointSignaturePath string +} + +type InspectCheckpointTransitionOptions struct { + InspectDefinitionOptions + PreviousCheckpointPath string + PreviousCheckpointSignaturePath string + CheckpointPath string + CheckpointSignaturePath string +} + +type InspectSubmissionOptions struct { + InspectCheckpointOptions + Kind string + Phase string + Index uint + SubmitterID string + AttemptID string + EnvelopePath string + EnvelopeSignaturePath string + ManifestPath string +} + +type InspectSubmissionAcknowledgementOptions struct { + InspectSubmissionOptions + AcknowledgementPath string + AcknowledgementSignaturePath string +} + +type CheckpointEvidenceOptions struct { + InspectDefinitionOptions + ArtifactRoot string + RelayReleaseID string + TransitionKind string + PreviousCheckpointPath string + PreviousCheckpointSignaturePath string + ChainPath string + ChainSignaturePath string + HeadPayloadPath string + Phase2GenesisPath string + Phase2ChainPath string + Phase2ChainSignaturePath string + Phase2HeadPayloadPath string + TransitionRecordPath string + TransitionRecordSignaturePath string + AcknowledgementPath string + AcknowledgementSignaturePath string + ManifestPath string + AttemptID string + ManifestKey string + NextAttemptID string + NextManifestKey string + CandidateDir string + ReleaseDir string +} + +type CheckpointPrepareOptions struct { + CheckpointEvidenceOptions + OutDir string +} + +type CheckpointSignOptions struct { + CheckpointEvidenceOptions + CheckpointPath string + SigningRequestPath string + CoordinatorSigningKey string + OutPath string +} + +type CheckpointVerifyOptions struct { + CheckpointEvidenceOptions + CheckpointPath string + CheckpointSignaturePath string +} + +type CheckpointVerifyStoredOptions struct { + InspectCheckpointOptions + ArtifactRoot string +} + type DefinitionInspection struct { Schema string `json:"schema"` CeremonyID string `json:"ceremony_id"` @@ -372,6 +465,84 @@ type EnrollmentInspection struct { IndependenceDisclosure mpcceremony.ArtifactRef `json:"independence_disclosure"` } +type CheckpointInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Workflow string `json:"workflow"` + RelayReleaseID string `json:"relay_release_id"` + Sequence uint64 `json:"sequence"` + Digest mpcceremony.Digest `json:"digest"` + Definition mpcceremony.SignedArtifactRefs `json:"definition"` + PreviousCheckpoint *mpcceremony.SignedArtifactRefs `json:"previous_checkpoint"` + Transition mpcceremony.CheckpointTransition `json:"transition"` + Phase1 mpcceremony.CheckpointPhaseState `json:"phase1"` + Phase1Closure *mpcceremony.SignedArtifactRefs `json:"phase1_closure,omitempty"` + Phase1Beacon *mpcceremony.SignedArtifactRefs `json:"phase1_beacon,omitempty"` + Phase1Seal *mpcceremony.SignedArtifactRefs `json:"phase1_seal,omitempty"` + Phase2 *mpcceremony.CheckpointPhaseState `json:"phase2,omitempty"` + Phase2Closure *mpcceremony.SignedArtifactRefs `json:"phase2_closure,omitempty"` + Phase2Beacon *mpcceremony.SignedArtifactRefs `json:"phase2_beacon,omitempty"` + FinalCandidate *mpcceremony.SignedArtifactRefs `json:"final_candidate,omitempty"` + FinalRelease *mpcceremony.SignedArtifactRefs `json:"final_release,omitempty"` + Submissions []mpcceremony.CheckpointSubmissionSlot `json:"submissions"` + AcceptedArtifacts []mpcceremony.ArtifactRef `json:"accepted_artifacts"` +} + +type CheckpointTransitionInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + PreviousSequence uint64 `json:"previous_sequence"` + Sequence uint64 `json:"sequence"` + PreviousCheckpointDigest mpcceremony.Digest `json:"previous_checkpoint_digest"` + PreviousSignatureDigest mpcceremony.Digest `json:"previous_signature_digest"` + CheckpointDigest mpcceremony.Digest `json:"checkpoint_digest"` + Transition mpcceremony.CheckpointTransition `json:"transition"` + Checkpoint CheckpointInspection `json:"checkpoint"` +} + +type CheckpointEvidenceInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Sequence uint64 `json:"sequence"` + CheckpointDigest mpcceremony.Digest `json:"checkpoint_digest"` + TransitionKind mpcceremony.CheckpointTransitionKind `json:"transition_kind"` + FullyVerified bool `json:"fully_verified"` + VerifiedEvidenceBoundary string `json:"verified_evidence_boundary"` +} + +type SubmissionInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Workflow string `json:"workflow"` + RelayReleaseID string `json:"relay_release_id"` + SubmitterID string `json:"submitter_id"` + SubmitterKeyID string `json:"submitter_key_id"` + SubmitterRole string `json:"submitter_role"` + Kind mpcceremony.CheckpointSubmissionKind `json:"kind"` + Phase mpcceremony.Phase `json:"phase"` + Index uint8 `json:"index"` + ParentCheckpointSHA256 string `json:"parent_checkpoint_sha256"` + AllocationCheckpointSHA256 string `json:"allocation_checkpoint_sha256"` + ParentHeadID string `json:"parent_head_id"` + AttemptID string `json:"attempt_id"` + ManifestKey string `json:"manifest_key"` + Payloads []mpcceremony.ArtifactRef `json:"payloads"` + EnvelopeDigest mpcceremony.Digest `json:"envelope_digest"` + EnvelopeSignatureDigest mpcceremony.Digest `json:"envelope_signature_digest"` + ManifestDigest mpcceremony.Digest `json:"manifest_digest"` +} + +type SubmissionAcknowledgementInspection struct { + Schema string `json:"schema"` + Submission SubmissionInspection `json:"submission"` + CoordinatorID string `json:"coordinator_id"` + CoordinatorKeyID string `json:"coordinator_key_id"` + Result mpcceremony.SubmissionAcknowledgementResult `json:"result"` + ReasonCode string `json:"reason_code,omitempty"` + AcknowledgementDigest mpcceremony.Digest `json:"acknowledgement_digest"` + AcknowledgementSignatureDigest mpcceremony.Digest `json:"acknowledgement_signature_digest"` +} + type DecisionSignOptions struct { CeremonyPath string CeremonySignaturePath string @@ -420,30 +591,35 @@ type ReplayOptions struct { } type CommandResult struct { - ReleaseManifestSHA256 string `json:"release_manifest_sha256,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"` - Identity *mpcceremony.Identity `json:"identity,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"` - JourneyInspection *JourneyInspection `json:"journey_inspection,omitempty"` + ReleaseManifestSHA256 string `json:"release_manifest_sha256,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"` + Identity *mpcceremony.Identity `json:"identity,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"` + CheckpointInspection *CheckpointInspection `json:"checkpoint_inspection,omitempty"` + CheckpointTransitionInspection *CheckpointTransitionInspection `json:"checkpoint_transition_inspection,omitempty"` + CheckpointEvidenceInspection *CheckpointEvidenceInspection `json:"checkpoint_evidence_inspection,omitempty"` + SubmissionInspection *SubmissionInspection `json:"submission_inspection,omitempty"` + SubmissionAcknowledgementInspection *SubmissionAcknowledgementInspection `json:"submission_acknowledgement_inspection,omitempty"` + JourneyInspection *JourneyInspection `json:"journey_inspection,omitempty"` } type Executor interface { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 17e920f0..b0f117c2 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -56,10 +56,16 @@ 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 + checkpoint prepare Re-derive a supported ceremony checkpoint from authenticated evidence + checkpoint sign Re-derive and sign an exact reviewed ceremony checkpoint + checkpoint verify Fully verify a signed ceremony checkpoint and its evidence + checkpoint verify-stored Infer and fully verify a fetched checkpoint ancestry 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 + inspect checkpoint Authenticate a storage-first workflow checkpoint + inspect checkpoint-transition Authenticate one legal checkpoint edge ops prepare-enrollment Derive your ceremony-bound public enrollment ops sign Sign your reviewed enrollment or observation offline ops prepare-public-witness-receipt Prepare witnessed closure bytes @@ -145,25 +151,32 @@ never generated automatically. Private key bytes are never printed. `, "rehearsal": `Usage: mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR \ - [--beacon-lead-seconds N] [--allowed-binary FILE ...] + [--beacon-lead-seconds N] [--allowed-binary FILE ...] \ + [--disable-optional-assurance] 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 \ - [--beacon-lead-seconds N] [--allowed-binary FILE ...] + [--beacon-lead-seconds N] [--allowed-binary FILE ...] \ + [--disable-optional-assurance] 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. -The witness window defaults to 300 seconds. --beacon-lead-seconds may shorten -it to at least 12 seconds for automated tests; the chosen non-production value -is signed into the rehearsal definition and cannot change production policy. +The beacon lead defaults to 300 seconds. --beacon-lead-seconds may shorten it +to at least 12 seconds for automated tests. The chosen value is signed into +the rehearsal definition. Production ceremonies configure the same field in +their policy JSON; production tooling should recommend 24 hours and clearly +warn before signing a shorter policy. With production witnesses enabled, the +close also reserves the fixed witness-observation window. +--disable-optional-assurance creates an explicit zero-witness, zero-mirror, +zero-ceremony-audit rehearsal while retaining future drand verification. `, "inspect": inspectHelp + ` Authenticated record projections are also available as subcommands: - mpc-ceremony inspect [flags] + mpc-ceremony inspect [flags] These subcommands are read-only and machine-readable. They perform no network access, replay, signing, or writes. @@ -203,6 +216,106 @@ writes and never emits private-key bytes. 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. +`, + "inspect checkpoint": `Usage: + mpc-ceremony --format json inspect checkpoint --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --checkpoint FILE --checkpoint-signature FILE + +Authenticates the exact canonical checkpoint against the independently trusted +ceremony definition and coordinator key. Reports the bounded workflow state, +submission slots, predecessor references, and artifact inventory. It does not +fetch or replay the protocol artifacts referenced by the checkpoint. +`, + "inspect checkpoint-transition": `Usage: + mpc-ceremony --format json inspect checkpoint-transition --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --previous-checkpoint FILE --previous-checkpoint-signature FILE \ + --checkpoint FILE --checkpoint-signature FILE + +Authenticates both exact signed checkpoints, verifies that the child binds the +exact parent record and detached signature, and enforces the legal structural +transition. It does not fetch or replay the protocol artifacts referenced by +that transition. +`, + "checkpoint": `Usage: + mpc-ceremony checkpoint [flags] + +Guarded storage-first checkpoint operations. Every operation re-authenticates +the exact signed definition, predecessor, both phase chains and all records +that cause the transition. The authenticated lifecycle runs from initialization +through both phases, the fully replayed final candidate, and the exact signed +release tree. Candidate acceptance and finalization replay the contribution +mathematics and cleanup evidence. +`, + "checkpoint prepare": `Usage: + mpc-ceremony checkpoint prepare --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --relay-release-id ID --transition KIND --chain FILE \ + --chain-signature FILE --head-payload FILE [transition flags] --out-dir DIR + +Re-derives a canonical supported ceremony checkpoint from authenticated evidence and +writes canonical.json plus signing-request.json to a fresh directory. + +For phase1-outbound-published also supply the previous checkpoint pair, signed +outbound handoff pair, --attempt-id, and --manifest-key. For +phase1-receipt-accepted supply the previous checkpoint pair, signed submission +envelope pair, signed acknowledgement pair, exact --manifest, +--next-attempt-id, and --next-manifest-key. + +For phase1-candidate-accepted supply the previous checkpoint pair, fully +verified next chain and head payload, candidate envelope pair, accepted +acknowledgement pair, and exact manifest. Attempt scope is derived from the +preallocated candidate slot. + +For phase1-closed supply the previous checkpoint pair and signed Phase 1 close +record pair. For phase1-beacon-recorded supply the previous checkpoint pair +and signed Phase 1 beacon record pair; the raw response named by the beacon +record must exist under the artifact root. + +For phase1-sealed supply the previous checkpoint pair and signed Phase 1 seal +record pair. Relay fully replays Phase 1 and requires the exact commons.bin +named by that seal under the artifact root. + +Phase 2 uses the corresponding --phase2-* inputs. For +final-candidate-recorded supply the canonical --candidate-dir final/candidate. +For final-release-recorded supply the canonical --release-dir final/release; +the complete release, including operational evidence and any required audits, +is strictly verified and closed against extra files. +`, + "checkpoint sign": `Usage: + mpc-ceremony checkpoint sign [all checkpoint prepare evidence flags] \ + --checkpoint FILE --signing-request FILE \ + --coordinator-signing-key KEY --out FRESH_FILE + +Re-derives the checkpoint from all exact evidence, requires byte-for-byte +agreement with the reviewed checkpoint and signing request, checks that the +private key belongs to the authenticated coordinator, then signs it. +`, + "checkpoint verify": `Usage: + mpc-ceremony --format json checkpoint verify \ + [all checkpoint prepare evidence flags] \ + --checkpoint FILE --checkpoint-signature FILE + +Re-derives and authenticates the signed checkpoint and all transition-defining +evidence within the supported lifecycle boundary. Its JSON projection sets fully_verified +only after those checks pass. Structural inspect checkpoint output must not be +used to advance Relay's trusted high-water state. +`, + "checkpoint verify-stored": `Usage: + mpc-ceremony --format json checkpoint verify-stored \ + --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE + +Walks the fetched checkpoint ancestry and derives every evidence path and +transition input from the authenticated checkpoints themselves. Every +supported ceremony edge is fully re-derived through the signed final release; +candidate acceptance and finalization replay contribution mathematics and +cleanup, while closure and beacon validation use the exact authenticated head +and raw beacon response. +Only this command (or checkpoint verify with explicit evidence) emits +fully_verified=true. Structural inspect output is diagnostics-only. `, "init": `Usage: mpc-ceremony init --key-version ownership-destination-v2 \ @@ -427,18 +540,21 @@ Release authenticity is separate from MPC contribution identity. "release sign": `Usage: mpc-ceremony release sign --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --candidate-bundle DIR \ - --audit-report FILE --audit-signature FILE \ + [--audit-report FILE --audit-signature FILE]... \ --operational-evidence-root DIR \ --operational-bundle DIR/operational/evidence-bundle.json \ --operational-bundle-signature DIR/operational/evidence-bundle.sig \ --release-signing-key KEY --signature-key-id ID \ --released-at RFC3339_UTC --release-dir FRESH_DIR +` + replayFlagsHelp + ` - Requires at least one enrolled auditor plus the coordinator-signed - Phase 1 and Phase 2 operational bundle. Each phase must contain a valid public - witness quorum and matching multi-relay beacon responses. The candidate is + Requires at least the signed minimum number of passing ceremony audits + assurance policy, plus the coordinator-signed Phase 1 and Phase 2 operational + bundle. Witness and mirror evidence likewise follows that signed policy; + multi-relay beacon evidence remains required. The candidate is never mutated; all verified evidence is atomically published into a fresh - release directory. + release directory. For current ceremonies, the release signer independently + replays both phases even when the signed audit minimum is zero. `, "release verify": `Usage: mpc-ceremony release verify --ceremony FILE --ceremony-signature FILE \ @@ -460,7 +576,8 @@ entropy quality, erasure, public witnessing, mirrors, or attendance. mpc-ceremony decision prepare --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --draft FILE --out FRESH_FILE -Strictly parses a proof-tool-mpc-production-decision-draft-v1 record, derives +Strictly parses a production decision draft matching the authenticated +ceremony schema, derives the release_id and decision_id, and checks ceremony, source, exact K=21 circuit, and signer-role bindings. The fresh output is the only byte string the accountable roles should sign. @@ -582,8 +699,8 @@ Run ops verify afterwards; receipts require --related-record and bundles require `, "ops prepare-bundle": `Usage: mpc-ceremony ops prepare-bundle --ceremony FILE --ceremony-signature FILE \ - --coordinator-public-key-file KEY --evidence-root PUBLIC_DIR --out-dir PUBLIC_DIR/operational \ - [--witness-quorum 1] + --coordinator-public-key-file KEY --evidence-root PUBLIC_DIR \ + --out-dir PUBLIC_DIR/operational Discovers bounded public JSON and signatures; never point it at private keys or credentials. Reports missing or conflicting evidence by phase and turn. Keep @@ -591,8 +708,8 @@ original relative paths when collecting public records from their owners. The operational directory may exist; existing evidence is preserved. Bundle, signature and signing-request files must not already exist. Interrupted output is retained for inspection, never automatically overwritten. -Set witness-quorum to your agreed minimum per phase (1-32), not a lower value -chosen to fit the available receipts. +Witness and mirror requirements come from the authenticated ceremony +definition; the operator cannot weaken them to fit the available evidence. If complete, independently verifies all referenced evidence and exports an UNSIGNED canonical bundle and signing request. It does not invent records, backdate observations, or sign for other roles. Release still requires the diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index 490879a1..ed71b4dd 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -31,7 +31,7 @@ Protect `main` with required review, required CI checks, CODEOWNERS review for release workflows, no direct pushes, and no force pushes. GitHub Actions is therefore part of the trusted release boundary. -New ceremony definitions use schema v2. The coordinator runs either released +New ceremony definitions use schema v3. The coordinator runs either released binary and passes the other with repeated `--allowed-binary FILE` flags during `init` (or `rehearsal init`). Initialization reads the embedded Go build metadata and rejects different source commits, dependency versions, Go @@ -101,3 +101,57 @@ 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, and releasing proof-tool never requires selecting a Relay commit. + +## Experimental optional controls + +The storage-first design introduces definition v3 with a signed +`assurance_policy`. Witnesses, mirrors, ceremony audits, and external security +audit signoffs each have an explicit minimum and may independently be zero. +The policy is repeated and checked across checkpoints, operational evidence, +the final transcript, and the production decision. Legacy schemas retain their +previous minimums. See [Optional ceremony controls](single-observer-minimum.md). + +The same signed definition contains the beacon lead. Rehearsal and production +both accept a positive configured value; 300 seconds and 24 hours respectively +are tooling defaults, not verifier-enforced mode floors. A shorter production +lead reduces the time available for public observation and review, so the +coordinator-facing tool must warn before signing it. When production witnesses +are enabled, close validation also reserves the fixed witness-observation +window in addition to the configured lead. + +Storage-first verification requires Relay to place the canonical accepted +artifact tree in a private local staging directory and prevent other local +processes from changing it during verification. Checkpoint metadata reads use +descriptor-relative, no-follow access on Linux and macOS. The existing large +transcript replay path still reopens files by pathname, so the system does not +claim protection against a malicious local process or compromised host racing +the verifier. Signed hashes and full replay continue to detect backend +corruption and ordinary local changes. + +The signed checkpoint graph uses the same canonical Phase 1 paths consumed by +Phase 2 (`phase1/chain-NNNN.*`, `phase1/closure/*`, `phase1/beacon/*`, and +`phase1/sealed/*`). Checkpoint creation rejects alternate aliases, so a fully +verified Phase 1 graph cannot depend on hidden duplicate files before Phase 2. +The next checkpoint fully replays that sealed Phase 1 state and accepts only +the deterministic zero-contribution Phase 2 tree at +`phase2/chain-0000.json`, `phase2/chain-0000.sig`, and +`phase2/genesis.bin`. Merely uploading files with those names is insufficient. +Phase 2 participant checkpoints then use the same ordered +outbound-handoff, signed-receipt, and accepted-candidate transitions as Phase 1. +Each accepted candidate is fully replayed against the sealed Phase 1 commons. +After the configured minimum is met, the graph accepts the canonical signed +Phase 2 closure and then the canonical signed beacon record plus its raw drand +response. Both edges preserve the exact Phase 1 seal, Phase 2 head, and all +submission results; stored verification replays that complete ancestry. +The next guarded edge independently replays both phases and accepts only the +closed `final/candidate` tree: the coordinator-signed candidate, exact checksum +inventory, final keys, Phase 2 seal, and public proof-verification evidence. +Extra, missing, symbolic-link, nonregular or changed files are rejected. + +For current definitions, release signing also independently replays both +phases on the release signer's machine even when ceremony audits are disabled. +The following checkpoint accepts only the strictly verified closed +`final/release` tree, including its release-signer manifest signature, +operational evidence, explicit audit inventory, transcript, keys and checksums. +It rejects missing, extra, symbolic-link, nonregular or changed entries. +GO/NO-GO authorization and public publication remain later, separate states. diff --git a/docs/single-observer-minimum.md b/docs/single-observer-minimum.md index c6ce62bb..321d726c 100644 --- a/docs/single-observer-minimum.md +++ b/docs/single-observer-minimum.md @@ -1,23 +1,34 @@ -# Single-observer minimums (unreleased) +# Optional ceremony controls (experimental) -Both rehearsal and production require at least one enrolled auditor, one public -witness per phase, and one mirror operator providing a signed receipt for every -accepted contribution. The same witness and mirror may serve both phases. A -higher explicitly selected witness quorum remains binding. Zero is rejected. +Definition v3 signs one explicit assurance policy with four independent +minimums: -Release signing requires at least one verified passing transcript audit. -Production decisions also require at least one distinct external audit signoff; -this is a separate report requirement, not evidence supplied by a mirror or -witness. Every supplied report is still validated, including additional reports. -The `release sign` command accepts one matching audit report/signature pair and -rejects zero, matching the verifier's threshold. +- public witnesses per phase; +- mirror receipts per accepted head; +- passing ceremony audits; and +- external security-audit signoffs. -Production still requires at least two participants per phase, every scheduled -contribution, distinct signing identities and the existing independence checks. -Beacon lead times and multi-relay observation requirements are unchanged. +Zero explicitly disables a control. Omitting the policy never means zero. +When ceremony audits are disabled, the signed auditor roster must also be +empty. Rehearsals must disable external security-audit signoffs because they +cannot satisfy a production review requirement honestly. -This policy changes verifier behavior and needs a new proof-tool release. Existing -ceremonies must continue using their approved proof-tool build. The companion CLI -must pin the new release and advertise the new `two-phase-v2` ruleset (version 2); -Tessera must provision that exact compatible CLI release before activating it. -Do not reinterpret old ceremonies using a newer verifier. +The policy is part of the ceremony ID. Checkpoints, operational evidence, +final transcripts, and production decisions repeat it and must match the +signed definition exactly. Evidence for a disabled control is rejected; an +enabled control must meet its signed minimum. A production decision displays a +disabled gate as `NOT_REQUIRED`, with no evidence, and may not use that status +for an enabled control. + +Disabling witnesses does not disable the future drand beacon or its +verification. It does remove independent evidence that the coordinator +published the closure before learning the beacon value. The signed timestamps +remain operator claims, not trusted external time. + +Definition v1/v2, operational-bundle v2, final-transcript v1, and +production-decision v1 keep their original one-or-more requirements. They are +not reinterpreted using the optional-role policy. + +This work is experimental until the full Proof-tool release path, Relay role +journeys, storage backends, and Tessera contract are updated and tested as one +released pairing. diff --git a/internal/mpcceremony/adversarial_test.go b/internal/mpcceremony/adversarial_test.go index 5d95222c..3b79bcdf 100644 --- a/internal/mpcceremony/adversarial_test.go +++ b/internal/mpcceremony/adversarial_test.go @@ -463,8 +463,8 @@ func TestCeremonyDefinitionRejectsMetadataDrift(t *testing.T) { {name: "beacon policy weakened", mutate: func(d *CeremonyDefinition) { d.BeaconPolicy.FutureRoundRequired = false }}, - {name: "beacon witness lead weakened", mutate: func(d *CeremonyDefinition) { - d.BeaconPolicy.MinimumWitnessLeadSeconds = ProductionMinimumWitnessLeadSeconds - 1 + {name: "beacon witness lead removed", mutate: func(d *CeremonyDefinition) { + d.BeaconPolicy.MinimumWitnessLeadSeconds = 0 }}, {name: "beacon chain replaced", mutate: func(d *CeremonyDefinition) { d.BeaconPolicy.ChainHashHex = strings.Repeat("00", 32) @@ -1176,10 +1176,14 @@ func TestReleaseRequiresExactChronologicalIndependentAudits(t *testing.T) { t.Fatal("audit predating candidate finalization unexpectedly accepted") } - if err := validateReleaseChronology(latest, latest); err == nil { + candidateTime := latest.Add(-time.Hour) + if err := validateReleaseChronology(candidateTime, candidateTime, time.Time{}); err == nil { + t.Fatal("zero-audit release at candidate finalization unexpectedly accepted") + } + if err := validateReleaseChronology(latest, candidateTime, latest); err == nil { t.Fatal("release at the latest audit timestamp unexpectedly accepted") } - if err := validateReleaseChronology(latest.Add(time.Nanosecond), latest); err != nil { + if err := validateReleaseChronology(latest.Add(time.Nanosecond), candidateTime, latest); err != nil { t.Fatalf("release strictly after the latest audit rejected: %v", err) } } diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index e4540807..92758829 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -66,6 +66,8 @@ type SignReleaseOptions struct { ReleaseSigningKey string SignatureKeyID string ReleasedAt time.Time + Replay *ReplayPaths + Circuit *CompiledCircuit } type SignReleaseResult struct { @@ -205,6 +207,81 @@ func ReplayCandidate(paths ReplayPaths, circuit *CompiledCircuit, candidateDir s return replay.definition.CeremonyID, nil } +// VerifyFinalCandidateCheckpoint fully replays a finalized candidate and +// returns the exact closed file inventory a storage-first checkpoint may +// commit. Extra files, missing files, symbolic links, other non-regular +// entries, and changed bytes are rejected. Regular hard links are treated as +// ordinary files; the checkpoint commits their exact contents, not inode +// identity. +func VerifyFinalCandidateCheckpoint(paths ReplayPaths, circuit *CompiledCircuit, candidateDir string) (CandidateMetadata, []ArtifactRef, error) { + if circuit == nil || circuit.R1CS == nil { + return CandidateMetadata{}, nil, errors.New("independently compiled circuit is required") + } + replay, err := loadReplay(paths) + if err != nil { + return CandidateMetadata{}, nil, err + } + if err := VerifyRunningSoftwareForMode(replay.definition.Software, replay.definition.Mode); err != nil { + return CandidateMetadata{}, nil, err + } + if err := ValidateCircuitBinding(circuit, replay.definition.Circuit); err != nil { + return CandidateMetadata{}, nil, err + } + candidate, candidateRef, err := verifyCandidate(replay.definition, replay.definitionRef, candidateDir) + if err != nil { + return CandidateMetadata{}, nil, err + } + if err := verifyCandidateReplay(circuit, &replay, paths, candidate, candidateDir); err != nil { + return CandidateMetadata{}, nil, err + } + names := append(candidateChecksumNames(), CandidateChecksumsFile) + expected := make(map[string]struct{}, len(names)) + for _, name := range names { + expected[name] = struct{}{} + } + entries, err := os.ReadDir(candidateDir) + if err != nil { + return CandidateMetadata{}, nil, err + } + refs := make([]ArtifactRef, 0, len(names)) + for _, entry := range entries { + name := entry.Name() + if _, ok := expected[name]; !ok { + return CandidateMetadata{}, nil, fmt.Errorf("unexpected finalized candidate entry %q", name) + } + info, err := entry.Info() + if err != nil { + return CandidateMetadata{}, nil, err + } + if !info.Mode().IsRegular() { + return CandidateMetadata{}, nil, fmt.Errorf("finalized candidate entry %q is not a regular file", name) + } + ref, err := artifactRefForFile(name, filepath.Join(candidateDir, name)) + if err != nil { + return CandidateMetadata{}, nil, err + } + refs = append(refs, ref) + delete(expected, name) + } + if len(expected) != 0 { + return CandidateMetadata{}, nil, errors.New("finalized candidate tree is incomplete") + } + slices.SortFunc(refs, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + for _, ref := range refs { + if ref.Name == CandidateMetadataFile && ref != candidateRef { + return CandidateMetadata{}, nil, errors.New("finalized candidate record changed during verification") + } + } + verifiedAgain, candidateRefAgain, err := verifyCandidate(replay.definition, replay.definitionRef, candidateDir) + if err != nil { + return CandidateMetadata{}, nil, fmt.Errorf("finalized candidate changed during closed-tree verification: %w", err) + } + if !reflect.DeepEqual(verifiedAgain, candidate) || candidateRefAgain != candidateRef { + return CandidateMetadata{}, nil, errors.New("finalized candidate changed during closed-tree verification") + } + return candidate, refs, nil +} + func verifyCandidateReplay(circuit *CompiledCircuit, replay *loadedReplay, paths ReplayPaths, candidate CandidateMetadata, dir string) error { phase2Seal, err := loadCandidatePhase2Seal(replay.definition, candidate, dir) if err != nil { @@ -214,6 +291,21 @@ func verifyCandidateReplay(circuit *CompiledCircuit, replay *loadedReplay, paths return fmt.Errorf("candidate phase2 seal: %w", err) } replay.phase2Seal = phase2Seal + phase1Summary, err := phaseSummary(replay.phase1Chain, replay.phase1ChainRef, replay.phase1Close, replay.phase1Beacon, replay.phase1Seal) + if err != nil { + return fmt.Errorf("derive phase1 candidate summary: %w", err) + } + phase2Summary, err := phaseSummary(replay.phase2Chain, replay.phase2ChainRef, replay.phase2Close, replay.phase2Beacon, replay.phase2Seal) + if err != nil { + return fmt.Errorf("derive phase2 candidate summary: %w", err) + } + var report VerificationReport + if _, err := readCanonicalFile(filepath.Join(dir, VerificationReportFile), &report); err != nil { + return fmt.Errorf("candidate verification report: %w", err) + } + if err := validateCandidateReplayClaims(candidate, phase1Summary, phase2Summary, phase2Seal, report); err != nil { + return err + } replayed, err := replayAll(circuit, *replay, paths) if err != nil { return err @@ -221,6 +313,16 @@ func verifyCandidateReplay(circuit *CompiledCircuit, replay *loadedReplay, paths return compareCandidateToReplay(circuit, *replay, replayed.pk, replayed.vk, candidate, dir) } +func validateCandidateReplayClaims(candidate CandidateMetadata, phase1, phase2 PhaseSummary, phase2Seal SealRecord, report VerificationReport) error { + if !reflect.DeepEqual(candidate.Phase1, phase1) || !reflect.DeepEqual(candidate.Phase2, phase2) { + return errors.New("candidate phase summaries do not equal the authenticated replay") + } + if candidate.FinalizedAt != phase2Seal.SealedAt || candidate.FinalizedAt != report.CheckedAt { + return errors.New("candidate finalization, phase2 seal, and verification report timestamps must match exactly") + } + return nil +} + func compareCandidateToReplay( circuit *CompiledCircuit, replay loadedReplay, @@ -297,8 +399,8 @@ func compareCandidateToReplay( return nil } -// SignRelease validates at least one enrolled, signed passing -// audits, assembles the final setup transcript and key manifest without +// SignRelease validates the signed definition's required passing-audit count, +// assembles the final setup transcript and key manifest without // replacing candidate files, then signs the exact manifest with the distinct // pre-existing release key. func SignRelease(options SignReleaseOptions) (*SignReleaseResult, error) { @@ -329,6 +431,14 @@ func SignRelease(options SignReleaseOptions) (*SignReleaseResult, error) { if err != nil { return nil, err } + if definition.Schema == DefinitionSchema { + if options.Replay == nil || options.Circuit == nil { + return nil, errors.New("storage-first release signing requires independent two-phase replay inputs") + } + if _, err := ReplayCandidate(*options.Replay, options.Circuit, options.CandidateDir); err != nil { + return nil, fmt.Errorf("release-signer independent replay: %w", err) + } + } if options.SignatureKeyID != definition.ReleaseSigner.KeyID { return nil, fmt.Errorf( "release signature key id %q, want signed definition key id %q", @@ -347,7 +457,11 @@ func SignRelease(options SignReleaseOptions) (*SignReleaseResult, error) { if err != nil { return nil, err } - if err := validateReleaseChronology(options.ReleasedAt, latestAudit); err != nil { + candidateTime, err := time.Parse(time.RFC3339Nano, candidate.FinalizedAt) + if err != nil { + return nil, fmt.Errorf("candidate finalized_at: %w", err) + } + if err := validateReleaseChronology(options.ReleasedAt, candidateTime, latestAudit); err != nil { return nil, err } operationalEvidence, err := verifyReleaseOperationalEvidence( @@ -419,8 +533,8 @@ func SignRelease(options SignReleaseOptions) (*SignReleaseResult, error) { return nil, errors.New("bundled operational evidence differs from verified release input") } transcript, err := NewFinalTranscript(FinalTranscript{ - Schema: FinalTranscriptSchema, CeremonyID: definition.CeremonyID, + AssurancePolicy: cloneAssurancePolicy(definition.AssurancePolicy), Definition: definitionRef, Circuit: definition.Circuit, Phase1: candidate.Phase1, @@ -598,7 +712,8 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { return nil, err } transcriptTime, _ := time.Parse(time.RFC3339Nano, transcript.FinalizedAt) - if err := validateReleaseChronology(transcriptTime, latestAudit); err != nil { + candidateTime, _ := time.Parse(time.RFC3339Nano, candidate.FinalizedAt) + if err := validateReleaseChronology(transcriptTime, candidateTime, latestAudit); err != nil { return nil, fmt.Errorf("final transcript: %w", err) } operationalEvidence, err := verifyReleaseOperationalEvidence( @@ -614,6 +729,7 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { return nil, fmt.Errorf("required operational evidence: %w", err) } if transcript.CeremonyID != definition.CeremonyID || + !reflect.DeepEqual(transcript.AssurancePolicy, definition.AssurancePolicy) || transcript.Definition != definitionRef || !equalCircuitBinding(transcript.Circuit, definition.Circuit) || !reflect.DeepEqual(transcript.Phase1, candidate.Phase1) || @@ -688,6 +804,66 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { return &VerifyReleaseResult{Manifest: manifest, Transcript: transcript, Candidate: candidate, ManifestSHA256: manifestRef.Digest.SHA256}, nil } +// VerifyFinalReleaseCheckpoint verifies the signed release and returns its +// exact closed regular-file inventory relative to KeysDir. The caller may add +// a storage prefix, but must not change names or digests. +func VerifyFinalReleaseCheckpoint(options VerifyReleaseOptions) (*VerifyReleaseResult, []ArtifactRef, error) { + verified, err := VerifyRelease(options) + if err != nil { + return nil, nil, err + } + refs := []ArtifactRef{} + err = filepath.WalkDir(options.KeysDir, func(path string, entry fs.DirEntry, walkErr error) error { + if walkErr != nil { + return walkErr + } + if path == options.KeysDir { + return nil + } + if entry.Type()&fs.ModeSymlink != 0 { + return fmt.Errorf("final release path is a symbolic link: %s", path) + } + if entry.IsDir() { + return nil + } + info, err := entry.Info() + if err != nil { + return err + } + if !info.Mode().IsRegular() { + return fmt.Errorf("final release entry is not a regular file: %s", path) + } + name, err := filepath.Rel(options.KeysDir, path) + if err != nil { + return err + } + ref, err := artifactRefForFile(filepath.ToSlash(name), path) + if err != nil { + return err + } + refs = append(refs, ref) + if len(refs) > MaxCheckpointArtifacts { + return fmt.Errorf("final release file count exceeds %d", MaxCheckpointArtifacts) + } + return nil + }) + if err != nil { + return nil, nil, err + } + slices.SortFunc(refs, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + verifiedAgain, err := VerifyRelease(options) + if err != nil { + return nil, nil, fmt.Errorf("final release changed during closed-tree verification: %w", err) + } + if verified.ManifestSHA256 != verifiedAgain.ManifestSHA256 || + !reflect.DeepEqual(verified.Transcript, verifiedAgain.Transcript) || + !reflect.DeepEqual(verified.Candidate, verifiedAgain.Candidate) || + !reflect.DeepEqual(verified.Manifest, verifiedAgain.Manifest) { + return nil, nil, errors.New("final release changed during closed-tree verification") + } + return verified, refs, nil +} + func verifyCandidate( definition CeremonyDefinition, definitionRef ArtifactRef, @@ -897,8 +1073,15 @@ func verifyPassingAudits( candidate CandidateMetadata, inputs []AuditArtifact, ) ([]ArtifactRef, time.Time, error) { - if len(inputs) < 1 { - return nil, time.Time{}, errors.New("at least one independently signed audit report is required") + minimum := 1 + if definition.Schema == DefinitionSchema { + minimum = int(definition.AssurancePolicy.PassingCeremonyAudits) + } + if len(inputs) < minimum { + return nil, time.Time{}, fmt.Errorf("have %d passing ceremony audits, need %d", len(inputs), minimum) + } + if minimum == 0 && len(inputs) != 0 { + return nil, time.Time{}, errors.New("ceremony audit artifacts are forbidden when audits are disabled") } replayRoot, err := replayRootSHA256(candidate) if err != nil { @@ -988,8 +1171,11 @@ func verifyPassingAudits( return refs, latestAudit, nil } -func validateReleaseChronology(releasedAt, latestAudit time.Time) error { - if !releasedAt.After(latestAudit) { +func validateReleaseChronology(releasedAt, candidateFinalizedAt, latestAudit time.Time) error { + if !releasedAt.After(candidateFinalizedAt) { + return errors.New("released_at must strictly postdate candidate finalization") + } + if !latestAudit.IsZero() && !releasedAt.After(latestAudit) { return errors.New("released_at must strictly postdate every accepted independent audit") } return nil @@ -1358,6 +1544,9 @@ func copyOperationalEvidence( // 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) { + if len(inputs) == 0 { + return []AuditArtifact{}, nil + } auditDir := filepath.Join(stagingDir, "audits") if err := os.Mkdir(auditDir, 0o700); err != nil { return nil, err diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index 74c5e7c1..06d77c87 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -608,7 +608,9 @@ func ValidateClose(definition CeremonyDefinition, chain Chain, close CloseRecord // witness receipt unsatisfiable. See ProductionWitnessObservationWindowSeconds. func requiredCloseLead(definition CeremonyDefinition) time.Duration { lead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second - if definition.Mode == ModeProduction { + witnessesEnabled := definition.Schema != DefinitionSchema || + (definition.AssurancePolicy != nil && definition.AssurancePolicy.PublicWitnessesPerPhase > 0) + if definition.Mode == ModeProduction && witnessesEnabled { lead += time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second } return lead @@ -1146,6 +1148,7 @@ type FinalTranscript struct { Schema string `json:"schema"` TranscriptID string `json:"transcript_id"` CeremonyID string `json:"ceremony_id"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` Definition ArtifactRef `json:"definition"` Circuit CircuitBinding `json:"circuit"` Phase1 PhaseSummary `json:"phase1"` @@ -1159,7 +1162,16 @@ type FinalTranscript struct { } func NewFinalTranscript(record FinalTranscript) (FinalTranscript, error) { - record.Schema = FinalTranscriptSchema + if record.Schema == "" { + if record.AssurancePolicy == nil { + record.Schema = FinalTranscriptSchemaV1 + } else { + record.Schema = FinalTranscriptSchema + } + } + if record.Schema == FinalTranscriptSchema && record.Audits == nil { + record.Audits = []ArtifactRef{} + } record.TranscriptID = "" id, err := ComputeFinalTranscriptID(record) if err != nil { @@ -1174,7 +1186,11 @@ func ComputeFinalTranscriptID(record FinalTranscript) (string, error) { if err := record.validate(false); err != nil { return "", err } - return canonicalHash("proof-tool/mpc-ceremony/final-transcript/v1", record) + domain := "proof-tool/mpc-ceremony/final-transcript/v2" + if record.Schema == FinalTranscriptSchemaV1 { + domain = "proof-tool/mpc-ceremony/final-transcript/v1" + } + return canonicalHash(domain, record) } func (r FinalTranscript) Validate() error { @@ -1192,8 +1208,20 @@ func (r FinalTranscript) Validate() error { } func (r FinalTranscript) validate(requireID bool) error { - if r.Schema != FinalTranscriptSchema { - return fmt.Errorf("transcript schema %q, want %q", r.Schema, FinalTranscriptSchema) + switch r.Schema { + case FinalTranscriptSchema: + if r.AssurancePolicy == nil { + return errors.New("final transcript v2 requires assurance_policy") + } + if r.Audits == nil { + return errors.New("final transcript v2 requires an explicit audits array; use [] when audits are disabled") + } + case FinalTranscriptSchemaV1: + if r.AssurancePolicy != nil { + return errors.New("final transcript v1 must not contain assurance_policy") + } + default: + return fmt.Errorf("transcript schema %q is unsupported", r.Schema) } if requireID { if err := validateHashID("transcript_id", r.TranscriptID); err != nil { @@ -1223,11 +1251,21 @@ func (r FinalTranscript) validate(requireID bool) error { if r.Phase2.Phase != Phase2 { return errors.New("phase2 summary has wrong phase") } - if len(r.Audits) < 1 { + if r.Schema == FinalTranscriptSchemaV1 && len(r.Audits) < 1 { return errors.New("final transcript requires at least one independent audit artifact") } - if err := validateArtifactList("audits", r.Audits, MaxParticipants); err != nil { - return err + if r.Schema == FinalTranscriptSchema { + if len(r.Audits) < int(r.AssurancePolicy.PassingCeremonyAudits) { + return fmt.Errorf("final transcript has %d audits, below signed minimum %d", len(r.Audits), r.AssurancePolicy.PassingCeremonyAudits) + } + if r.AssurancePolicy.PassingCeremonyAudits == 0 && len(r.Audits) != 0 { + return errors.New("final transcript contains audits while ceremony audits are disabled") + } + } + if len(r.Audits) > 0 { + if err := validateArtifactList("audits", r.Audits, MaxParticipants); err != nil { + return err + } } if err := r.OperationalEvidence.Validate(); err != nil { return fmt.Errorf("operational_evidence: %w", err) diff --git a/internal/mpcceremony/chain_test.go b/internal/mpcceremony/chain_test.go index 48e5f4e2..1a23aac6 100644 --- a/internal/mpcceremony/chain_test.go +++ b/internal/mpcceremony/chain_test.go @@ -396,6 +396,9 @@ func TestCloseRequiresCompleteProductionRosterButKeepsRehearsalThreshold(t *test rehearsal := adversarialDefinition(t) rehearsal.CeremonyID = "" rehearsal.Mode = ModeRehearsal + assurance := *rehearsal.AssurancePolicy + assurance.ExternalSecurityAuditSignoffs = 0 + rehearsal.AssurancePolicy = &assurance rehearsal.Phase1Policy = clonePhasePolicy(rehearsal.Phase1Policy) rehearsal.Phase1Policy.Minimum = 2 rehearsal.Phase2Policy = clonePhasePolicy(rehearsal.Phase2Policy) diff --git a/internal/mpcceremony/checkpoint.go b/internal/mpcceremony/checkpoint.go new file mode 100644 index 00000000..e4613dc2 --- /dev/null +++ b/internal/mpcceremony/checkpoint.go @@ -0,0 +1,1139 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "reflect" + "slices" + "strings" +) + +const ( + CheckpointSchemaV1 = "proof-tool-mpc-checkpoint-v1" + CheckpointSchemaV2 = "proof-tool-mpc-checkpoint-v2" + CheckpointSchema = "proof-tool-mpc-checkpoint-v3" + StorageFirstWorkflowV1 = "storage-first-v1" + CheckpointSigningRequestSchemaV1 = "proof-tool-mpc-checkpoint-signing-request-v1" + // Every participant can contribute once in each of two phases. Each turn has + // three checkpoint edges and currently adds at most 21 immutable references. + // The fixed lifecycle reserve covers initialization, closure, beacons, + // finalization, decisions, and modest schema growth without making parsing + // unbounded. + MaxCheckpointAncestry = 2*MaxParticipants*3 + 64 + MaxCheckpointArtifacts = 2*MaxParticipants*21 + 256 +) + +// CheckpointSigningRequest lets an isolated coordinator signer confirm that it +// is signing the exact checkpoint bytes re-derived from authenticated evidence. +type CheckpointSigningRequest struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + CoordinatorKeyID string `json:"coordinator_key_id"` + Checkpoint Digest `json:"checkpoint"` +} + +func NewCheckpointSigningRequest(definition CeremonyDefinition, checkpointBytes []byte) (CheckpointSigningRequest, error) { + if err := definition.Validate(); err != nil { + return CheckpointSigningRequest{}, err + } + if len(checkpointBytes) == 0 { + return CheckpointSigningRequest{}, errors.New("checkpoint bytes are required") + } + request := CheckpointSigningRequest{ + Schema: CheckpointSigningRequestSchemaV1, CeremonyID: definition.CeremonyID, + CoordinatorKeyID: definition.Coordinator.KeyID, Checkpoint: NewDigest(checkpointBytes), + } + return request, request.Validate() +} + +func (r CheckpointSigningRequest) Validate() error { + if r.Schema != CheckpointSigningRequestSchemaV1 { + return fmt.Errorf("checkpoint signing request schema %q, want %q", r.Schema, CheckpointSigningRequestSchemaV1) + } + if err := validateHashID("ceremony_id", r.CeremonyID); err != nil { + return err + } + if err := validateID("coordinator_key_id", r.CoordinatorKeyID); err != nil { + return err + } + if err := r.Checkpoint.Validate(); err != nil { + return fmt.Errorf("checkpoint digest: %w", err) + } + return nil +} + +type CheckpointTransitionKind string + +const ( + CheckpointInitial CheckpointTransitionKind = "initial" + CheckpointPhase1OutboundPublished CheckpointTransitionKind = "phase1-outbound-published" + CheckpointPhase1ReceiptAccepted CheckpointTransitionKind = "phase1-receipt-accepted" + CheckpointPhase1CandidateAccepted CheckpointTransitionKind = "phase1-candidate-accepted" + CheckpointPhase1Closed CheckpointTransitionKind = "phase1-closed" + CheckpointPhase1BeaconRecorded CheckpointTransitionKind = "phase1-beacon-recorded" + CheckpointPhase1Sealed CheckpointTransitionKind = "phase1-sealed" + CheckpointPhase2Initialized CheckpointTransitionKind = "phase2-initialized" + CheckpointPhase2OutboundPublished CheckpointTransitionKind = "phase2-outbound-published" + CheckpointPhase2ReceiptAccepted CheckpointTransitionKind = "phase2-receipt-accepted" + CheckpointPhase2CandidateAccepted CheckpointTransitionKind = "phase2-candidate-accepted" + CheckpointPhase2Closed CheckpointTransitionKind = "phase2-closed" + CheckpointPhase2BeaconRecorded CheckpointTransitionKind = "phase2-beacon-recorded" + CheckpointFinalCandidateRecorded CheckpointTransitionKind = "final-candidate-recorded" + CheckpointFinalReleaseRecorded CheckpointTransitionKind = "final-release-recorded" +) + +type CheckpointSubmissionKind string + +const ( + CheckpointSubmissionReceipt CheckpointSubmissionKind = "receipt" + CheckpointSubmissionCandidate CheckpointSubmissionKind = "candidate" +) + +type CheckpointSubmissionStatus string + +const ( + CheckpointSubmissionAllocated CheckpointSubmissionStatus = "allocated" + CheckpointSubmissionAccepted CheckpointSubmissionStatus = "accepted" +) + +// CheckpointPhaseState is the small authenticated projection needed to decide +// where a phase is. The signed chain remains the authority for its contents; +// this projection never substitutes for chain verification. +type CheckpointPhaseState struct { + Phase Phase `json:"phase"` + AcceptedCount uint8 `json:"accepted_count"` + HeadRecordID string `json:"head_record_id"` + HeadPayload ArtifactRef `json:"head_payload"` + Chain SignedArtifactRefs `json:"chain"` +} + +func (s CheckpointPhaseState) Validate() error { + if s.Phase != Phase1 && s.Phase != Phase2 { + return fmt.Errorf("checkpoint phase %q, want phase1 or phase2", s.Phase) + } + if s.AcceptedCount > MaxParticipants { + return fmt.Errorf("accepted_count %d exceeds maximum %d", s.AcceptedCount, MaxParticipants) + } + if err := validateHashID("head_record_id", s.HeadRecordID); err != nil { + return err + } + if err := s.HeadPayload.Validate(); err != nil { + return fmt.Errorf("head_payload: %w", err) + } + if err := s.Chain.Validate(); err != nil { + return fmt.Errorf("chain: %w", err) + } + return nil +} + +// CheckpointSubmissionSlot is coordinator-allocated public protocol state. A +// credential may expire without changing the slot or its attempt identifier. +type CheckpointSubmissionSlot struct { + Kind CheckpointSubmissionKind `json:"kind"` + Phase Phase `json:"phase"` + Index uint8 `json:"index"` + IdentityID string `json:"identity_id"` + AttemptID string `json:"attempt_id"` + ManifestKey string `json:"manifest_key"` + BasisCheckpointSHA256 string `json:"basis_checkpoint_sha256"` + ParentHeadID string `json:"parent_head_id"` + Status CheckpointSubmissionStatus `json:"status"` + Acknowledgement *SignedArtifactRefs `json:"acknowledgement"` +} + +func (s CheckpointSubmissionSlot) Validate() error { + switch s.Kind { + case CheckpointSubmissionReceipt, CheckpointSubmissionCandidate: + default: + return fmt.Errorf("unsupported checkpoint submission kind %q", s.Kind) + } + if s.Phase != Phase1 && s.Phase != Phase2 { + return fmt.Errorf("submission phase %q, want phase1 or phase2", s.Phase) + } + if s.Index == 0 || s.Index > MaxParticipants { + return fmt.Errorf("submission index %d must be between 1 and %d", s.Index, MaxParticipants) + } + if err := validateID("submission identity_id", s.IdentityID); err != nil { + return err + } + if err := validateHex(s.AttemptID, 16); err != nil { + return fmt.Errorf("submission attempt_id: %w", err) + } + if err := validateArtifactName(s.ManifestKey); err != nil { + return fmt.Errorf("submission manifest_key: %w", err) + } + if err := validatePortableStorageName(s.ManifestKey); err != nil { + return fmt.Errorf("submission manifest_key: %w", err) + } + if !strings.HasSuffix(s.ManifestKey, "/manifest.json") { + return errors.New("submission manifest_key must end in /manifest.json") + } + if err := validateHashID("basis_checkpoint_sha256", s.BasisCheckpointSHA256); err != nil { + return err + } + if err := validateHashID("parent_head_id", s.ParentHeadID); err != nil { + return err + } + switch s.Status { + case CheckpointSubmissionAllocated: + if s.Acknowledgement != nil { + return errors.New("allocated submission must not have an acknowledgement") + } + case CheckpointSubmissionAccepted: + if s.Acknowledgement == nil { + return errors.New("accepted submission requires an acknowledgement") + } + if err := s.Acknowledgement.Validate(); err != nil { + return fmt.Errorf("submission acknowledgement: %w", err) + } + default: + return fmt.Errorf("unsupported checkpoint submission status %q", s.Status) + } + return nil +} + +func (s CheckpointSubmissionSlot) key() string { + kindOrder := "1" + if s.Kind == CheckpointSubmissionReceipt { + kindOrder = "0" + } + return string(s.Phase) + "\x00" + fmt.Sprintf("%03d", s.Index) + "\x00" + s.IdentityID + "\x00" + kindOrder + "\x00" + s.AttemptID +} + +// CheckpointTransition states the one protocol edge represented by a child +// checkpoint. Record identifies the role-authored input; acknowledgement is +// present only for coordinator acceptance edges. +type CheckpointTransition struct { + Kind CheckpointTransitionKind `json:"kind"` + Phase Phase `json:"phase"` + Index uint8 `json:"index"` + ParticipantID string `json:"participant_id"` + AttemptID string `json:"attempt_id"` + NextAttemptID string `json:"next_attempt_id"` + Record *SignedArtifactRefs `json:"record"` + Acknowledgement *SignedArtifactRefs `json:"acknowledgement"` + Evidence []ArtifactRef `json:"evidence"` +} + +func (t CheckpointTransition) Validate() error { + if t.Kind == CheckpointInitial { + if t.Phase != "" || t.Index != 0 || t.ParticipantID != "" || t.AttemptID != "" || + t.NextAttemptID != "" || t.Record != nil || t.Acknowledgement != nil || len(t.Evidence) != 0 { + return errors.New("initial transition must not contain turn fields") + } + return nil + } + if t.Kind == CheckpointPhase1Closed || t.Kind == CheckpointPhase2Closed { + expectedPhase := Phase1 + if t.Kind == CheckpointPhase2Closed { + expectedPhase = Phase2 + } + if t.Phase != expectedPhase || t.Index != 0 || t.ParticipantID != "" || t.AttemptID != "" || + t.NextAttemptID != "" || t.Record == nil || t.Acknowledgement != nil || len(t.Evidence) != 0 { + return fmt.Errorf("%s closure transition must contain only the signed closure record", expectedPhase) + } + if err := t.Record.Validate(); err != nil { + return fmt.Errorf("transition record: %w", err) + } + return nil + } + if t.Kind == CheckpointPhase1BeaconRecorded || t.Kind == CheckpointPhase2BeaconRecorded { + expectedPhase := Phase1 + if t.Kind == CheckpointPhase2BeaconRecorded { + expectedPhase = Phase2 + } + if t.Phase != expectedPhase || t.Index != 0 || t.ParticipantID != "" || t.AttemptID != "" || + t.NextAttemptID != "" || t.Record == nil || t.Acknowledgement != nil || len(t.Evidence) != 1 { + return fmt.Errorf("%s beacon transition requires only the signed beacon record and one raw response", expectedPhase) + } + if err := t.Record.Validate(); err != nil { + return fmt.Errorf("transition record: %w", err) + } + if err := t.Evidence[0].Validate(); err != nil { + return fmt.Errorf("transition beacon evidence: %w", err) + } + return nil + } + if t.Kind == CheckpointPhase1Sealed { + if t.Phase != Phase1 || t.Index != 0 || t.ParticipantID != "" || t.AttemptID != "" || + t.NextAttemptID != "" || t.Record == nil || t.Acknowledgement != nil || len(t.Evidence) != 1 { + return errors.New("phase1 seal transition requires only the signed seal record and one commons file") + } + if err := t.Record.Validate(); err != nil { + return fmt.Errorf("transition record: %w", err) + } + if err := t.Evidence[0].Validate(); err != nil { + return fmt.Errorf("transition seal evidence: %w", err) + } + return nil + } + if t.Kind == CheckpointFinalCandidateRecorded || t.Kind == CheckpointFinalReleaseRecorded { + if t.Phase != "" || t.Index != 0 || t.ParticipantID != "" || t.AttemptID != "" || + t.NextAttemptID != "" || t.Record == nil || t.Acknowledgement != nil || len(t.Evidence) < 1 { + return fmt.Errorf("%s transition requires only its signed primary record and closed file inventory", t.Kind) + } + if err := t.Record.Validate(); err != nil { + return fmt.Errorf("transition record: %w", err) + } + for index, ref := range t.Evidence { + if err := ref.Validate(); err != nil { + return fmt.Errorf("transition %s evidence %d: %w", t.Kind, index, err) + } + if index > 0 && t.Evidence[index-1].Name >= ref.Name { + return fmt.Errorf("transition %s evidence must be strictly sorted by unique name", t.Kind) + } + } + return nil + } + if t.Kind == CheckpointPhase2Initialized { + if t.Phase != Phase2 || t.Index != 0 || t.ParticipantID != "" || t.AttemptID != "" || + t.NextAttemptID != "" || t.Record == nil || t.Acknowledgement != nil || len(t.Evidence) != 1 { + return errors.New("phase2 initialization transition requires only the signed genesis chain and genesis payload") + } + if err := t.Record.Validate(); err != nil { + return fmt.Errorf("transition record: %w", err) + } + if err := t.Evidence[0].Validate(); err != nil { + return fmt.Errorf("transition phase2 genesis: %w", err) + } + return nil + } + expectedPhase := Phase1 + switch t.Kind { + case CheckpointPhase2OutboundPublished, CheckpointPhase2ReceiptAccepted, CheckpointPhase2CandidateAccepted: + expectedPhase = Phase2 + } + if t.Phase != expectedPhase { + return fmt.Errorf("transition phase %q, want %s", t.Phase, expectedPhase) + } + if t.Index == 0 || t.Index > MaxParticipants { + return fmt.Errorf("transition index %d must be between 1 and %d", t.Index, MaxParticipants) + } + if err := validateID("transition participant_id", t.ParticipantID); err != nil { + return err + } + if err := validateHex(t.AttemptID, 16); err != nil { + return fmt.Errorf("transition attempt_id: %w", err) + } + if t.Record == nil { + return errors.New("transition record is required") + } + if err := t.Record.Validate(); err != nil { + return fmt.Errorf("transition record: %w", err) + } + switch t.Kind { + case CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished: + if t.NextAttemptID != "" || t.Acknowledgement != nil || len(t.Evidence) != 0 { + return errors.New("outbound transition must not contain a next attempt or acknowledgement") + } + case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: + if err := validateHex(t.NextAttemptID, 16); err != nil { + return fmt.Errorf("transition next_attempt_id: %w", err) + } + if t.NextAttemptID == t.AttemptID { + return errors.New("receipt and candidate attempts must be distinct") + } + if t.Acknowledgement == nil { + return errors.New("receipt acceptance requires an acknowledgement") + } + if err := t.Acknowledgement.Validate(); err != nil { + return fmt.Errorf("transition acknowledgement: %w", err) + } + if err := validateCheckpointTransitionEvidence(t.Evidence); err != nil { + return err + } + case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + if t.NextAttemptID != "" || t.Acknowledgement == nil { + return errors.New("candidate acceptance requires an acknowledgement and no next attempt") + } + if err := t.Acknowledgement.Validate(); err != nil { + return fmt.Errorf("transition acknowledgement: %w", err) + } + if err := validateCheckpointTransitionEvidence(t.Evidence); err != nil { + return err + } + default: + return fmt.Errorf("unsupported checkpoint transition kind %q", t.Kind) + } + return nil +} + +func validateCheckpointTransitionEvidence(evidence []ArtifactRef) error { + if len(evidence) < 2 || len(evidence) > 65 { + return errors.New("accepted submission transition requires between 2 and 65 evidence artifacts") + } + for i, ref := range evidence { + if err := ref.Validate(); err != nil { + return fmt.Errorf("transition evidence %d: %w", i, err) + } + if i > 0 && evidence[i-1].Name >= ref.Name { + return errors.New("transition evidence must be strictly sorted by unique name") + } + } + return nil +} + +// Checkpoint is canonical coordinator-authored state. Object-store pointers, +// provider versions, credentials and grants deliberately do not appear here. +type Checkpoint struct { + Schema string `json:"schema"` + Workflow string `json:"workflow"` + CeremonyID string `json:"ceremony_id"` + Definition SignedArtifactRefs `json:"definition"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` + RelayReleaseID string `json:"relay_release_id"` + Sequence uint64 `json:"sequence"` + PreviousCheckpoint *SignedArtifactRefs `json:"previous_checkpoint"` + Transition CheckpointTransition `json:"transition"` + Phase1 CheckpointPhaseState `json:"phase1"` + Phase1Closure *SignedArtifactRefs `json:"phase1_closure,omitempty"` + Phase1Beacon *SignedArtifactRefs `json:"phase1_beacon,omitempty"` + Phase1Seal *SignedArtifactRefs `json:"phase1_seal,omitempty"` + Phase2 *CheckpointPhaseState `json:"phase2,omitempty"` + Phase2Closure *SignedArtifactRefs `json:"phase2_closure,omitempty"` + Phase2Beacon *SignedArtifactRefs `json:"phase2_beacon,omitempty"` + FinalCandidate *SignedArtifactRefs `json:"final_candidate,omitempty"` + FinalRelease *SignedArtifactRefs `json:"final_release,omitempty"` + AcceptedArtifacts []ArtifactRef `json:"accepted_artifacts"` + Submissions []CheckpointSubmissionSlot `json:"submissions"` +} + +func (c Checkpoint) Validate() error { + switch c.Schema { + case CheckpointSchema, CheckpointSchemaV2: + if c.AssurancePolicy == nil { + return fmt.Errorf("checkpoint %q requires assurance_policy", c.Schema) + } + case CheckpointSchemaV1: + if c.AssurancePolicy != nil { + return errors.New("checkpoint v1 must not contain assurance_policy") + } + default: + return fmt.Errorf("checkpoint schema %q, want %q, %q or %q", c.Schema, CheckpointSchemaV1, CheckpointSchemaV2, CheckpointSchema) + } + if c.Workflow != StorageFirstWorkflowV1 { + return fmt.Errorf("checkpoint workflow %q, want %q", c.Workflow, StorageFirstWorkflowV1) + } + if err := validateHashID("ceremony_id", c.CeremonyID); err != nil { + return err + } + if err := c.Definition.Validate(); err != nil { + return fmt.Errorf("definition: %w", err) + } + if err := validateID("relay_release_id", c.RelayReleaseID); err != nil { + return err + } + if c.Sequence == 0 { + if c.PreviousCheckpoint != nil || c.Transition.Kind != CheckpointInitial { + return errors.New("sequence zero must be the initial checkpoint with no predecessor") + } + } else { + if c.PreviousCheckpoint == nil { + return errors.New("non-initial checkpoint requires previous_checkpoint") + } + if err := c.PreviousCheckpoint.Validate(); err != nil { + return fmt.Errorf("previous_checkpoint: %w", err) + } + if c.Transition.Kind == CheckpointInitial { + return errors.New("initial transition is permitted only at sequence zero") + } + } + if err := c.Transition.Validate(); err != nil { + return fmt.Errorf("transition: %w", err) + } + if c.Schema != CheckpointSchema && (c.Phase1Closure != nil || c.Phase1Beacon != nil || c.Phase1Seal != nil || c.hasPhase2Semantics() || + c.Transition.Kind == CheckpointPhase1Closed || c.Transition.Kind == CheckpointPhase1BeaconRecorded || c.Transition.Kind == CheckpointPhase1Sealed) { + return errors.New("phase1 closure and later lifecycle state require checkpoint v3") + } + if err := c.Phase1.Validate(); err != nil { + return fmt.Errorf("phase1: %w", err) + } + if c.Phase1.Phase != Phase1 { + return errors.New("phase1 state must identify phase1") + } + if c.Phase1Closure != nil { + if err := c.Phase1Closure.Validate(); err != nil { + return fmt.Errorf("phase1_closure: %w", err) + } + if !slices.Contains(c.AcceptedArtifacts, c.Phase1Closure.Record) || !slices.Contains(c.AcceptedArtifacts, c.Phase1Closure.Signature) { + return errors.New("phase1 closure must be present in accepted_artifacts") + } + } + if c.Phase1Beacon != nil { + if c.Phase1Closure == nil { + return errors.New("phase1 beacon requires a committed closure") + } + if err := c.Phase1Beacon.Validate(); err != nil { + return fmt.Errorf("phase1_beacon: %w", err) + } + if !slices.Contains(c.AcceptedArtifacts, c.Phase1Beacon.Record) || !slices.Contains(c.AcceptedArtifacts, c.Phase1Beacon.Signature) { + return errors.New("phase1 beacon must be present in accepted_artifacts") + } + } + if c.Phase1Seal != nil { + if c.Phase1Beacon == nil { + return errors.New("phase1 seal requires a recorded beacon") + } + if err := c.Phase1Seal.Validate(); err != nil { + return fmt.Errorf("phase1_seal: %w", err) + } + if !slices.Contains(c.AcceptedArtifacts, c.Phase1Seal.Record) || !slices.Contains(c.AcceptedArtifacts, c.Phase1Seal.Signature) { + return errors.New("phase1 seal must be present in accepted_artifacts") + } + } + if c.Phase2 != nil { + if c.Phase1Seal == nil { + return errors.New("phase2 state requires a phase1 seal") + } + if err := c.Phase2.Validate(); err != nil { + return fmt.Errorf("phase2: %w", err) + } + if c.Phase2.Phase != Phase2 { + return errors.New("phase2 state must identify phase2") + } + for _, ref := range []ArtifactRef{c.Phase2.Chain.Record, c.Phase2.Chain.Signature, c.Phase2.HeadPayload} { + if !slices.Contains(c.AcceptedArtifacts, ref) { + return errors.New("phase2 state artifacts must be present in accepted_artifacts") + } + } + } + if c.Phase2Closure != nil { + if c.Phase2 == nil { + return errors.New("phase2 closure requires phase2 state") + } + if err := c.Phase2Closure.Validate(); err != nil { + return fmt.Errorf("phase2_closure: %w", err) + } + if !slices.Contains(c.AcceptedArtifacts, c.Phase2Closure.Record) || !slices.Contains(c.AcceptedArtifacts, c.Phase2Closure.Signature) { + return errors.New("phase2 closure must be present in accepted_artifacts") + } + } + if c.Phase2Beacon != nil { + if c.Phase2Closure == nil { + return errors.New("phase2 beacon requires a committed closure") + } + if err := c.Phase2Beacon.Validate(); err != nil { + return fmt.Errorf("phase2_beacon: %w", err) + } + if !slices.Contains(c.AcceptedArtifacts, c.Phase2Beacon.Record) || !slices.Contains(c.AcceptedArtifacts, c.Phase2Beacon.Signature) { + return errors.New("phase2 beacon must be present in accepted_artifacts") + } + } + if c.FinalCandidate != nil { + if c.Phase2Beacon == nil { + return errors.New("final candidate requires a recorded phase2 beacon") + } + if err := c.FinalCandidate.Validate(); err != nil { + return fmt.Errorf("final_candidate: %w", err) + } + if !slices.Contains(c.AcceptedArtifacts, c.FinalCandidate.Record) || !slices.Contains(c.AcceptedArtifacts, c.FinalCandidate.Signature) { + return errors.New("final candidate must be present in accepted_artifacts") + } + } + if c.FinalRelease != nil { + if c.FinalCandidate == nil { + return errors.New("final release requires a committed final candidate") + } + if err := c.FinalRelease.Validate(); err != nil { + return fmt.Errorf("final_release: %w", err) + } + if !slices.Contains(c.AcceptedArtifacts, c.FinalRelease.Record) || !slices.Contains(c.AcceptedArtifacts, c.FinalRelease.Signature) { + return errors.New("final release must be present in accepted_artifacts") + } + } + if c.Transition.Phase == Phase2 && c.Phase2 == nil { + return errors.New("phase2 transition requires phase2 state") + } + if c.Sequence == 0 && (c.Phase1Closure != nil || c.Phase1Beacon != nil || c.Phase1Seal != nil || c.Phase2 != nil || c.Phase2Closure != nil || c.Phase2Beacon != nil || c.FinalCandidate != nil || c.FinalRelease != nil) { + return errors.New("initial checkpoint must not contain later lifecycle state") + } + if c.Sequence == 0 && (c.Phase1.AcceptedCount != 0 || len(c.Submissions) != 0) { + return errors.New("initial checkpoint must start before contributions and submissions") + } + if len(c.AcceptedArtifacts) == 0 || len(c.AcceptedArtifacts) > MaxCheckpointArtifacts { + return fmt.Errorf("accepted_artifacts must contain between 1 and %d entries", MaxCheckpointArtifacts) + } + for i, artifact := range c.AcceptedArtifacts { + if err := artifact.Validate(); err != nil { + return fmt.Errorf("accepted_artifacts %d: %w", i, err) + } + if err := validatePortableStorageName(artifact.Name); err != nil { + return fmt.Errorf("accepted_artifacts %d: %w", i, err) + } + if i > 0 && c.AcceptedArtifacts[i-1].Name >= artifact.Name { + return errors.New("accepted_artifacts must be strictly sorted by unique name") + } + } + if c.PreviousCheckpoint != nil { + for label, ref := range map[string]ArtifactRef{"record": c.PreviousCheckpoint.Record, "signature": c.PreviousCheckpoint.Signature} { + if err := validatePortableStorageName(ref.Name); err != nil { + return fmt.Errorf("previous checkpoint %s: %w", label, err) + } + } + } + attempts := make(map[string]struct{}, len(c.Submissions)) + manifests := make(map[string]struct{}, len(c.Submissions)) + for i, slot := range c.Submissions { + if err := slot.Validate(); err != nil { + return fmt.Errorf("submissions %d: %w", i, err) + } + if i > 0 && c.Submissions[i-1].key() >= slot.key() { + return errors.New("submissions must be strictly sorted with no duplicate attempt") + } + if _, exists := attempts[slot.AttemptID]; exists { + return errors.New("submission attempt IDs must be globally unique within a checkpoint") + } + attempts[slot.AttemptID] = struct{}{} + if _, exists := manifests[slot.ManifestKey]; exists { + return errors.New("submission manifest keys must be globally unique within a checkpoint") + } + manifests[slot.ManifestKey] = struct{}{} + if slot.Acknowledgement != nil && + (!slices.Contains(c.AcceptedArtifacts, slot.Acknowledgement.Record) || + !slices.Contains(c.AcceptedArtifacts, slot.Acknowledgement.Signature)) { + return fmt.Errorf("submissions %d acknowledgement must be present in accepted_artifacts", i) + } + } + if err := c.requireTransitionArtifacts(); err != nil { + return err + } + for label, ref := range map[string]ArtifactRef{ + "definition record": c.Definition.Record, + "definition signature": c.Definition.Signature, + "phase1 head payload": c.Phase1.HeadPayload, + "phase1 chain": c.Phase1.Chain.Record, + "phase1 chain signature": c.Phase1.Chain.Signature, + } { + if !slices.Contains(c.AcceptedArtifacts, ref) { + return fmt.Errorf("%s must be present in accepted_artifacts", label) + } + } + return nil +} + +func (c Checkpoint) hasPhase2Semantics() bool { + if c.Phase2 != nil || c.Phase2Closure != nil || c.Phase2Beacon != nil || c.Transition.Phase == Phase2 { + return true + } + return slices.ContainsFunc(c.Submissions, func(slot CheckpointSubmissionSlot) bool { return slot.Phase == Phase2 }) +} + +// Storage-first names are materialized on both Linux and macOS. Restricting +// them to lowercase ASCII prevents case-folding and Unicode-normalization +// collisions on default Mac filesystems while retaining the existing clean +// relative-path rule. +func validatePortableStorageName(name string) error { + for _, r := range name { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') || r == '/' || r == '.' || r == '-' || r == '_' { + continue + } + return errors.New("storage-first artifact names must use lowercase ASCII letters, numbers, slash, dot, dash, or underscore") + } + return nil +} + +func (c Checkpoint) requireTransitionArtifacts() error { + contains := func(ref ArtifactRef) bool { + return slices.Contains(c.AcceptedArtifacts, ref) + } + for label, signed := range map[string]*SignedArtifactRefs{ + "record": c.Transition.Record, + "acknowledgement": c.Transition.Acknowledgement, + } { + if signed != nil && (!contains(signed.Record) || !contains(signed.Signature)) { + return fmt.Errorf("transition %s must be present in accepted_artifacts", label) + } + } + for _, ref := range c.Transition.Evidence { + if !contains(ref) { + return errors.New("transition evidence must be present in accepted_artifacts") + } + } + return nil +} + +// VerifySignedCheckpoint authenticates exact canonical checkpoint bytes with +// the coordinator identity from the signed ceremony definition. The caller +// still verifies every referenced protocol artifact required by the edge. +func VerifySignedCheckpoint(definition CeremonyDefinition, definitionBytes, definitionSignatureBytes, checkpointBytes, signatureBytes []byte) (Checkpoint, error) { + if err := definition.Validate(); err != nil { + return Checkpoint{}, err + } + publicKey, err := identityPublicKey(definition.Coordinator) + if err != nil { + return Checkpoint{}, err + } + var authenticatedDefinition CeremonyDefinition + if err := VerifySignedRecord(definitionBytes, definitionSignatureBytes, &authenticatedDefinition, definition.Coordinator.KeyID, publicKey); err != nil { + return Checkpoint{}, fmt.Errorf("definition signature: %w", err) + } + if !reflect.DeepEqual(authenticatedDefinition, definition) { + return Checkpoint{}, errors.New("authenticated definition bytes do not match supplied definition") + } + var checkpoint Checkpoint + if err := VerifySignedRecord(checkpointBytes, signatureBytes, &checkpoint, definition.Coordinator.KeyID, publicKey); err != nil { + return Checkpoint{}, fmt.Errorf("checkpoint signature: %w", err) + } + if checkpoint.CeremonyID != definition.CeremonyID { + return Checkpoint{}, errors.New("checkpoint ceremony_id does not match definition") + } + if checkpoint.Definition.Record.Digest != NewDigest(definitionBytes) || checkpoint.Definition.Signature.Digest != NewDigest(definitionSignatureBytes) { + return Checkpoint{}, errors.New("checkpoint definition references do not match authenticated definition bytes") + } + if err := validateCheckpointDefinitionVersion(definition, checkpoint); err != nil { + return Checkpoint{}, err + } + return checkpoint, nil +} + +func validateCheckpointDefinitionVersion(definition CeremonyDefinition, checkpoint Checkpoint) error { + if definition.Schema == DefinitionSchema { + if (checkpoint.Schema != CheckpointSchema && checkpoint.Schema != CheckpointSchemaV2) || checkpoint.AssurancePolicy == nil || *checkpoint.AssurancePolicy != *definition.AssurancePolicy { + return errors.New("definition v3 requires a checkpoint v2 or v3 with exactly matching assurance_policy") + } + return nil + } + if checkpoint.Schema != CheckpointSchemaV1 || checkpoint.AssurancePolicy != nil { + return errors.New("legacy definition requires checkpoint v1 semantics") + } + return nil +} + +// ValidateCheckpointTransition verifies the legal structural edge between two +// canonical checkpoints. It does not replace authentication of the signed +// chain and role-authored records referenced by that edge. +func ValidateCheckpointTransition(previous, next Checkpoint) error { + if err := previous.Validate(); err != nil { + return fmt.Errorf("previous checkpoint: %w", err) + } + if err := next.Validate(); err != nil { + return fmt.Errorf("next checkpoint: %w", err) + } + previousBytes, err := MarshalCanonical(previous) + if err != nil { + return err + } + wantPrevious := NewDigest(previousBytes) + if next.PreviousCheckpoint == nil || next.PreviousCheckpoint.Record.Digest != wantPrevious { + return errors.New("next checkpoint does not bind the exact previous checkpoint") + } + if next.Sequence != previous.Sequence+1 { + return fmt.Errorf("next checkpoint sequence %d, want %d", next.Sequence, previous.Sequence+1) + } + if next.Schema != previous.Schema || next.Workflow != previous.Workflow || + next.CeremonyID != previous.CeremonyID || next.Definition != previous.Definition || + next.RelayReleaseID != previous.RelayReleaseID { + return errors.New("checkpoint immutable ceremony, workflow, definition, or release binding changed") + } + if !reflect.DeepEqual(next.AssurancePolicy, previous.AssurancePolicy) { + return errors.New("checkpoint assurance policy changed") + } + if !artifactSubset(previous.AcceptedArtifacts, next.AcceptedArtifacts) { + return errors.New("accepted artifact inventory is not append-only") + } + switch next.Transition.Kind { + case CheckpointPhase1OutboundPublished: + return validateOutboundTransition(previous, next, Phase1) + case CheckpointPhase1ReceiptAccepted: + return validateReceiptTransition(previous, next, Phase1) + case CheckpointPhase1CandidateAccepted: + return validateCandidateTransition(previous, next, Phase1) + case CheckpointPhase2OutboundPublished: + return validateOutboundTransition(previous, next, Phase2) + case CheckpointPhase2ReceiptAccepted: + return validateReceiptTransition(previous, next, Phase2) + case CheckpointPhase2CandidateAccepted: + return validateCandidateTransition(previous, next, Phase2) + case CheckpointPhase2Closed: + return validatePhase2ClosedTransition(previous, next) + case CheckpointPhase2BeaconRecorded: + return validatePhase2BeaconTransition(previous, next) + case CheckpointFinalCandidateRecorded: + return validateFinalCandidateTransition(previous, next) + case CheckpointFinalReleaseRecorded: + return validateFinalReleaseTransition(previous, next) + case CheckpointPhase1Closed: + return validatePhase1ClosedTransition(previous, next) + case CheckpointPhase1BeaconRecorded: + return validatePhase1BeaconTransition(previous, next) + case CheckpointPhase1Sealed: + return validatePhase1SealTransition(previous, next) + case CheckpointPhase2Initialized: + return validatePhase2InitializedTransition(previous, next) + default: + return fmt.Errorf("transition %q cannot follow another checkpoint", next.Transition.Kind) + } +} + +func validateFinalReleaseTransition(previous, next Checkpoint) error { + if previous.FinalCandidate == nil || next.FinalCandidate == nil || *previous.FinalCandidate != *next.FinalCandidate || previous.FinalRelease != nil || next.FinalRelease == nil { + return errors.New("final release must be added exactly once after the final candidate") + } + if !phase1LifecyclePreserved(previous, next) || previous.Phase2Closure == nil || next.Phase2Closure == nil || *previous.Phase2Closure != *next.Phase2Closure || + previous.Phase2Beacon == nil || next.Phase2Beacon == nil || *previous.Phase2Beacon != *next.Phase2Beacon || + previous.Phase2 == nil || next.Phase2 == nil || !samePhaseState(*previous.Phase2, *next.Phase2) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("final release must preserve the completed ceremony and submission slots") + } + if next.Transition.Record == nil || *next.Transition.Record != *next.FinalRelease { + return errors.New("final release transition must name the committed release manifest") + } + expected := append(signedArtifacts(next.FinalRelease), next.Transition.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("final release accepted an unexpected artifact set") + } + return nil +} + +func validateFinalCandidateTransition(previous, next Checkpoint) error { + if previous.Phase2Beacon == nil || next.Phase2Beacon == nil || *previous.Phase2Beacon != *next.Phase2Beacon || previous.FinalCandidate != nil || next.FinalCandidate == nil { + return errors.New("final candidate must be added exactly once after the phase2 beacon") + } + if !phase1LifecyclePreserved(previous, next) || previous.Phase2Closure == nil || next.Phase2Closure == nil || *previous.Phase2Closure != *next.Phase2Closure || + previous.Phase2 == nil || next.Phase2 == nil || !samePhaseState(*previous.Phase2, *next.Phase2) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("final candidate must preserve both completed phase states and submission slots") + } + if next.Transition.Record == nil || *next.Transition.Record != *next.FinalCandidate { + return errors.New("final candidate transition must name the committed candidate") + } + expected := append(signedArtifacts(next.FinalCandidate), next.Transition.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("final candidate accepted an unexpected artifact set") + } + return nil +} + +func phase1LifecyclePreserved(previous, next Checkpoint) bool { + return samePhaseState(previous.Phase1, next.Phase1) && + reflect.DeepEqual(previous.Phase1Closure, next.Phase1Closure) && + reflect.DeepEqual(previous.Phase1Beacon, next.Phase1Beacon) && + reflect.DeepEqual(previous.Phase1Seal, next.Phase1Seal) +} + +func validatePhase2ClosedTransition(previous, next Checkpoint) error { + if previous.Phase2 == nil || next.Phase2 == nil || previous.Phase2Closure != nil || next.Phase2Closure == nil { + return errors.New("phase2 closure must be added exactly once after phase2 initialization") + } + if !phase1LifecyclePreserved(previous, next) || !samePhaseState(*previous.Phase2, *next.Phase2) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("phase2 closure must preserve both phase heads and submission slots") + } + if hasAllocatedSubmission(previous.Submissions) { + return errors.New("phase2 cannot close while a submission attempt is still allocated") + } + if next.Transition.Record == nil || *next.Transition.Record != *next.Phase2Closure { + return errors.New("phase2 closure transition must name the committed closure") + } + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, signedArtifacts(next.Phase2Closure)...) { + return errors.New("phase2 closure accepted an unexpected artifact set") + } + return nil +} + +func validatePhase2BeaconTransition(previous, next Checkpoint) error { + if previous.Phase2 == nil || next.Phase2 == nil || previous.Phase2Closure == nil || next.Phase2Closure == nil || *previous.Phase2Closure != *next.Phase2Closure { + return errors.New("phase2 beacon must preserve an existing exact closure") + } + if previous.Phase2Beacon != nil || next.Phase2Beacon == nil { + return errors.New("phase2 beacon must be added exactly once") + } + if !phase1LifecyclePreserved(previous, next) || !samePhaseState(*previous.Phase2, *next.Phase2) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("phase2 beacon must preserve both phase heads and submission slots") + } + if next.Transition.Record == nil || *next.Transition.Record != *next.Phase2Beacon { + return errors.New("phase2 beacon transition must name the committed beacon") + } + expected := append(signedArtifacts(next.Phase2Beacon), next.Transition.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("phase2 beacon accepted an unexpected artifact set") + } + return nil +} + +func validatePhase2InitializedTransition(previous, next Checkpoint) error { + if previous.Phase1Seal == nil || next.Phase1Seal == nil || *previous.Phase1Seal != *next.Phase1Seal || + previous.Phase1Closure == nil || next.Phase1Closure == nil || *previous.Phase1Closure != *next.Phase1Closure || + previous.Phase1Beacon == nil || next.Phase1Beacon == nil || *previous.Phase1Beacon != *next.Phase1Beacon { + return errors.New("phase2 initialization must preserve sealed phase1 state") + } + if previous.Phase2 != nil || next.Phase2 == nil || next.Phase2.AcceptedCount != 0 { + return errors.New("phase2 initialization must add exactly one zero-contribution phase2 state") + } + if !samePhaseState(previous.Phase1, next.Phase1) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("phase2 initialization must preserve phase1 and submission slots") + } + if next.Transition.Record == nil || *next.Transition.Record != next.Phase2.Chain || len(next.Transition.Evidence) != 1 || next.Transition.Evidence[0] != next.Phase2.HeadPayload { + return errors.New("phase2 initialization transition must name its exact chain and genesis") + } + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, + next.Phase2.Chain.Record, next.Phase2.Chain.Signature, next.Phase2.HeadPayload) { + return errors.New("phase2 initialization accepted an unexpected artifact set") + } + return nil +} + +func validatePhase1SealTransition(previous, next Checkpoint) error { + if previous.Phase1Closure == nil || previous.Phase1Beacon == nil || next.Phase1Closure == nil || next.Phase1Beacon == nil || + *previous.Phase1Closure != *next.Phase1Closure || *previous.Phase1Beacon != *next.Phase1Beacon { + return errors.New("phase1 seal must preserve the exact closure and beacon") + } + if previous.Phase1Seal != nil || next.Phase1Seal == nil { + return errors.New("phase1 seal must be added exactly once") + } + if !samePhaseState(previous.Phase1, next.Phase1) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("phase1 seal must preserve the accepted head and submission slots") + } + if next.Transition.Record == nil || *next.Transition.Record != *next.Phase1Seal { + return errors.New("phase1 seal transition must name the committed seal") + } + expected := append(signedArtifacts(next.Phase1Seal), next.Transition.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("phase1 seal accepted an unexpected artifact set") + } + return nil +} + +func validatePhase1BeaconTransition(previous, next Checkpoint) error { + if previous.Phase1Closure == nil || next.Phase1Closure == nil || *previous.Phase1Closure != *next.Phase1Closure { + return errors.New("phase1 beacon must preserve an existing exact closure") + } + if previous.Phase1Beacon != nil || next.Phase1Beacon == nil { + return errors.New("phase1 beacon must be added exactly once") + } + if !samePhaseState(previous.Phase1, next.Phase1) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("phase1 beacon must preserve the accepted head and submission slots") + } + if next.Transition.Record == nil || *next.Transition.Record != *next.Phase1Beacon { + return errors.New("phase1 beacon transition must name the committed beacon") + } + expected := append(signedArtifacts(next.Phase1Beacon), next.Transition.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("phase1 beacon accepted an unexpected artifact set") + } + return nil +} + +func validatePhase1ClosedTransition(previous, next Checkpoint) error { + if previous.Phase1Closure != nil || next.Phase1Closure == nil { + return errors.New("phase1 closure must be added exactly once") + } + if !samePhaseState(previous.Phase1, next.Phase1) || !slotsEqual(previous.Submissions, next.Submissions) { + return errors.New("phase1 closure must preserve the accepted head and submission slots") + } + if hasAllocatedSubmission(previous.Submissions) { + return errors.New("phase1 cannot close while a submission attempt is still allocated") + } + if next.Transition.Record == nil || *next.Transition.Record != *next.Phase1Closure { + return errors.New("phase1 closure transition must name the committed closure") + } + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, signedArtifacts(next.Phase1Closure)...) { + return errors.New("phase1 closure accepted an unexpected artifact set") + } + return nil +} + +func hasAllocatedSubmission(slots []CheckpointSubmissionSlot) bool { + return slices.ContainsFunc(slots, func(slot CheckpointSubmissionSlot) bool { + return slot.Status == CheckpointSubmissionAllocated + }) +} + +func artifactSubset(previous, next []ArtifactRef) bool { + for _, ref := range previous { + if !slices.Contains(next, ref) { + return false + } + } + return true +} + +func samePhaseState(a, b CheckpointPhaseState) bool { return a == b } + +func transitionScopeMatchesSlot(t CheckpointTransition, s CheckpointSubmissionSlot, kind CheckpointSubmissionKind) bool { + return s.Kind == kind && s.Phase == t.Phase && s.Index == t.Index && + s.IdentityID == t.ParticipantID && s.AttemptID == t.AttemptID +} + +func slotEqual(a, b CheckpointSubmissionSlot) bool { return reflect.DeepEqual(a, b) } + +func slotsEqual(a, b []CheckpointSubmissionSlot) bool { return reflect.DeepEqual(a, b) } + +func signedArtifacts(refs *SignedArtifactRefs) []ArtifactRef { + if refs == nil { + return nil + } + return []ArtifactRef{refs.Record, refs.Signature} +} + +func exactArtifactDelta(previous, next []ArtifactRef, expected ...ArtifactRef) bool { + var actual []ArtifactRef + for _, ref := range next { + if !slices.Contains(previous, ref) { + actual = append(actual, ref) + } + } + slices.SortFunc(actual, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + slices.SortFunc(expected, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + return slices.Equal(actual, expected) +} + +func checkpointPhaseState(checkpoint Checkpoint, phase Phase) (*CheckpointPhaseState, error) { + if phase == Phase1 { + return &checkpoint.Phase1, nil + } + if phase == Phase2 && checkpoint.Phase2 != nil { + return checkpoint.Phase2, nil + } + return nil, fmt.Errorf("checkpoint has no %s state", phase) +} + +func otherPhaseStatePreserved(previous, next Checkpoint, phase Phase) bool { + if phase == Phase1 { + return reflect.DeepEqual(previous.Phase2, next.Phase2) + } + return phase1LifecyclePreserved(previous, next) +} + +func validateOutboundTransition(previous, next Checkpoint, phase Phase) error { + t := next.Transition + previousState, err := checkpointPhaseState(previous, phase) + if err != nil { + return err + } + nextState, err := checkpointPhaseState(next, phase) + if err != nil { + return err + } + if phase == Phase1 && (previous.Phase1Closure != nil || next.Phase1Closure != nil) { + return errors.New("phase1 turn cannot advance after closure") + } + if phase == Phase2 && (previous.Phase2Closure != nil || next.Phase2Closure != nil) { + return errors.New("phase2 turn cannot advance after closure") + } + if hasAllocatedSubmission(previous.Submissions) { + return errors.New("a new phase1 turn cannot open while another submission attempt is allocated") + } + if previousState.AcceptedCount == MaxParticipants || !samePhaseState(*previousState, *nextState) || !otherPhaseStatePreserved(previous, next, phase) || int(t.Index) != int(previousState.AcceptedCount)+1 { + return errors.New("outbound publication must preserve the head and target the next index") + } + if len(next.Submissions) != len(previous.Submissions)+1 || !slotsEqual(previous.Submissions, next.Submissions[:len(previous.Submissions)]) { + return errors.New("outbound publication must append exactly one submission slot") + } + slot := next.Submissions[len(next.Submissions)-1] + if !transitionScopeMatchesSlot(t, slot, CheckpointSubmissionReceipt) || slot.Status != CheckpointSubmissionAllocated || + slot.ParentHeadID != previousState.HeadRecordID || slot.BasisCheckpointSHA256 != next.PreviousCheckpoint.Record.Digest.SHA256 { + return errors.New("outbound publication must allocate the matching receipt attempt for the current head") + } + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, signedArtifacts(t.Record)...) { + return errors.New("outbound publication accepted an unexpected artifact set") + } + return nil +} + +func validateReceiptTransition(previous, next Checkpoint, phase Phase) error { + t := next.Transition + previousState, err := checkpointPhaseState(previous, phase) + if err != nil { + return err + } + nextState, err := checkpointPhaseState(next, phase) + if err != nil { + return err + } + if phase == Phase1 && (previous.Phase1Closure != nil || next.Phase1Closure != nil) { + return errors.New("phase1 receipt cannot be accepted after closure") + } + if phase == Phase2 && (previous.Phase2Closure != nil || next.Phase2Closure != nil) { + return errors.New("phase2 receipt cannot be accepted after closure") + } + if !samePhaseState(*previousState, *nextState) || !otherPhaseStatePreserved(previous, next, phase) || len(next.Submissions) != len(previous.Submissions)+1 { + return errors.New("receipt acceptance must preserve the head and append one candidate slot") + } + changed := -1 + for i := range previous.Submissions { + before, after := previous.Submissions[i], next.Submissions[i] + if slotEqual(before, after) { + continue + } + if changed >= 0 || !transitionScopeMatchesSlot(t, before, CheckpointSubmissionReceipt) || + before.Status != CheckpointSubmissionAllocated || after.Status != CheckpointSubmissionAccepted || + before.Kind != after.Kind || before.Phase != after.Phase || before.Index != after.Index || + before.IdentityID != after.IdentityID || before.AttemptID != after.AttemptID || + before.ManifestKey != after.ManifestKey || before.BasisCheckpointSHA256 != after.BasisCheckpointSHA256 || + before.ParentHeadID != after.ParentHeadID || after.Acknowledgement == nil || + *after.Acknowledgement != *t.Acknowledgement { + return errors.New("receipt acceptance changed a slot other than its acknowledgement and status") + } + changed = i + } + if changed < 0 { + return errors.New("receipt acceptance did not accept its allocated receipt slot") + } + candidate := next.Submissions[len(next.Submissions)-1] + if candidate.Kind != CheckpointSubmissionCandidate || candidate.Phase != t.Phase || candidate.Index != t.Index || + candidate.IdentityID != t.ParticipantID || candidate.AttemptID != t.NextAttemptID || + candidate.Status != CheckpointSubmissionAllocated || candidate.ParentHeadID != previousState.HeadRecordID || + candidate.BasisCheckpointSHA256 != next.PreviousCheckpoint.Record.Digest.SHA256 { + return errors.New("receipt acceptance must append the matching candidate attempt") + } + expected := append(signedArtifacts(t.Record), signedArtifacts(t.Acknowledgement)...) + expected = append(expected, t.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("receipt acceptance accepted an unexpected artifact set") + } + return nil +} + +func validateCandidateTransition(previous, next Checkpoint, phase Phase) error { + t := next.Transition + previousState, err := checkpointPhaseState(previous, phase) + if err != nil { + return err + } + nextState, err := checkpointPhaseState(next, phase) + if err != nil { + return err + } + if phase == Phase1 && (previous.Phase1Closure != nil || next.Phase1Closure != nil) { + return errors.New("phase1 candidate cannot be accepted after closure") + } + if phase == Phase2 && (previous.Phase2Closure != nil || next.Phase2Closure != nil) { + return errors.New("phase2 candidate cannot be accepted after closure") + } + if nextState.AcceptedCount != previousState.AcceptedCount+1 || nextState.AcceptedCount != t.Index || + nextState.HeadRecordID == previousState.HeadRecordID || nextState.HeadPayload == previousState.HeadPayload || + nextState.Chain == previousState.Chain || !otherPhaseStatePreserved(previous, next, phase) { + return fmt.Errorf("candidate acceptance must advance exactly one %s head", phase) + } + if len(next.Submissions) != len(previous.Submissions) { + return errors.New("candidate acceptance must not add or remove submission slots") + } + changed := -1 + for i := range previous.Submissions { + before, after := previous.Submissions[i], next.Submissions[i] + if slotEqual(before, after) { + continue + } + if changed >= 0 || !transitionScopeMatchesSlot(t, before, CheckpointSubmissionCandidate) || + before.Status != CheckpointSubmissionAllocated || after.Status != CheckpointSubmissionAccepted || + before.Kind != after.Kind || before.Phase != after.Phase || before.Index != after.Index || + before.IdentityID != after.IdentityID || before.AttemptID != after.AttemptID || + before.ManifestKey != after.ManifestKey || before.BasisCheckpointSHA256 != after.BasisCheckpointSHA256 || + before.ParentHeadID != after.ParentHeadID || after.Acknowledgement == nil || + *after.Acknowledgement != *t.Acknowledgement { + return errors.New("candidate acceptance changed a slot other than its acknowledgement and status") + } + changed = i + } + if changed < 0 { + return errors.New("candidate acceptance did not accept its allocated candidate slot") + } + expected := append(signedArtifacts(t.Record), signedArtifacts(t.Acknowledgement)...) + expected = append(expected, t.Evidence...) + // The accepted head payload is one of the submission envelope payloads and + // therefore already appears in transition evidence. The new signed chain + // is coordinator-authored state and is added separately. + expected = append(expected, nextState.Chain.Record, nextState.Chain.Signature) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("candidate acceptance accepted an unexpected artifact set") + } + return nil +} diff --git a/internal/mpcceremony/checkpoint_test.go b/internal/mpcceremony/checkpoint_test.go new file mode 100644 index 00000000..86e78c91 --- /dev/null +++ b/internal/mpcceremony/checkpoint_test.go @@ -0,0 +1,990 @@ +package mpcceremony + +import ( + "bytes" + "fmt" + "slices" + "strings" + "testing" +) + +func TestCheckpointSchemasPreserveLegacyAndForbidCrossVersionUse(t *testing.T) { + currentDefinition := adversarialDefinition(t) + current := phase1CheckpointSequence(t)[0] + current.AssurancePolicy = cloneAssurancePolicy(currentDefinition.AssurancePolicy) + if err := validateCheckpointDefinitionVersion(currentDefinition, current); err != nil { + t.Fatal(err) + } + previousCurrent := current + previousCurrent.Schema = CheckpointSchemaV2 + if err := validateCheckpointDefinitionVersion(currentDefinition, previousCurrent); err != nil { + t.Fatalf("existing checkpoint v2 rejected: %v", err) + } + + legacyCheckpoint := current + legacyCheckpoint.Schema = CheckpointSchemaV1 + legacyCheckpoint.AssurancePolicy = nil + if err := legacyCheckpoint.Validate(); err != nil { + t.Fatalf("legacy checkpoint rejected: %v", err) + } + raw, err := MarshalCanonical(legacyCheckpoint) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte("assurance_policy")) { + t.Fatal("legacy checkpoint canonical bytes gained a new field") + } + if err := validateCheckpointDefinitionVersion(currentDefinition, legacyCheckpoint); err == nil { + t.Fatal("definition v3 accepted checkpoint v1") + } + + legacyDefinition := currentDefinition + legacyDefinition.Schema = DefinitionSchemaV2 + legacyDefinition.AssurancePolicy = nil + legacyDefinition.CeremonyID = "" + legacyID, err := ComputeCeremonyID(legacyDefinition) + if err != nil { + t.Fatal(err) + } + legacyDefinition.CeremonyID = legacyID + if err := validateCheckpointDefinitionVersion(legacyDefinition, current); err == nil { + t.Fatal("legacy definition accepted checkpoint v3") + } + if err := validateCheckpointDefinitionVersion(legacyDefinition, legacyCheckpoint); err != nil { + t.Fatalf("legacy definition/checkpoint pairing rejected: %v", err) + } + + mismatch := current + other := *mismatch.AssurancePolicy + other.PublicWitnessesPerPhase++ + mismatch.AssurancePolicy = &other + if err := validateCheckpointDefinitionVersion(currentDefinition, mismatch); err == nil { + t.Fatal("checkpoint v2 accepted changed assurance policy") + } + + next := current + next.Schema = CheckpointSchemaV1 + next.AssurancePolicy = nil + next.Sequence = 1 + next.PreviousCheckpoint = &SignedArtifactRefs{} + if err := ValidateCheckpointTransition(current, next); err == nil { + t.Fatal("checkpoint transition switched schema versions") + } +} + +func TestCheckpointV2CannotClaimPhase1ClosureOrBeacon(t *testing.T) { + previous := phase1CheckpointSequence(t)[3] + previous.Schema = CheckpointSchemaV2 + closed := phase1ClosedCheckpoint(t, previous) + closed.Schema = CheckpointSchemaV2 + if err := closed.Validate(); err == nil || !strings.Contains(err.Error(), "require checkpoint v3") { + t.Fatalf("checkpoint v2 closure err=%v", err) + } + + closed.Schema = CheckpointSchema + beacon := cloneCheckpoint(t, closed) + beacon.Schema = CheckpointSchemaV2 + beacon.Phase1Beacon = func() *SignedArtifactRefs { value := checkpointSigned("legacy-beacon"); return &value }() + beacon.Transition = CheckpointTransition{Kind: CheckpointPhase1BeaconRecorded, Phase: Phase1, Record: beacon.Phase1Beacon, + Evidence: []ArtifactRef{checkpointArtifact("legacy-raw.json", "raw")}} + beacon.AcceptedArtifacts = appendCheckpointArtifacts(beacon.AcceptedArtifacts, beacon.Phase1Beacon.Record, beacon.Phase1Beacon.Signature, beacon.Transition.Evidence[0]) + if err := beacon.Validate(); err == nil || !strings.Contains(err.Error(), "require checkpoint v3") { + t.Fatalf("checkpoint v2 beacon err=%v", err) + } +} + +func checkpointArtifact(name, contents string) ArtifactRef { + return ArtifactRef{Name: name, Digest: NewDigest([]byte(contents))} +} + +func checkpointSigned(prefix string) SignedArtifactRefs { + return SignedArtifactRefs{ + Record: checkpointArtifact(prefix+".json", prefix+" record"), + Signature: checkpointArtifact(prefix+".sig", prefix+" signature"), + } +} + +func checkpointArtifacts(values ...ArtifactRef) []ArtifactRef { + result := append([]ArtifactRef(nil), values...) + slices.SortFunc(result, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + return result +} + +func appendCheckpointArtifacts(base []ArtifactRef, values ...ArtifactRef) []ArtifactRef { + return checkpointArtifacts(append(append([]ArtifactRef(nil), base...), values...)...) +} + +func checkpointReference(t *testing.T, checkpoint Checkpoint, suffix string) SignedArtifactRefs { + t.Helper() + raw, err := MarshalCanonical(checkpoint) + if err != nil { + t.Fatalf("marshal checkpoint reference: %v", err) + } + return SignedArtifactRefs{ + Record: ArtifactRef{ + Name: "state/checkpoints/checkpoint-" + suffix + ".json", + Digest: NewDigest(raw), + }, + Signature: checkpointArtifact("state/checkpoints/checkpoint-"+suffix+".sig", "checkpoint "+suffix+" signature"), + } +} + +func cloneCheckpoint(t *testing.T, checkpoint Checkpoint) Checkpoint { + t.Helper() + raw, err := MarshalCanonical(checkpoint) + if err != nil { + t.Fatalf("marshal checkpoint clone: %v", err) + } + var result Checkpoint + if err := UnmarshalCanonical(raw, &result); err != nil { + t.Fatalf("unmarshal checkpoint clone: %v", err) + } + return result +} + +func phase1CheckpointSequence(t *testing.T) [4]Checkpoint { + t.Helper() + definition := checkpointSigned("00-definition") + genesis := checkpointArtifact("01-phase1-genesis.bin", "genesis") + chain0 := checkpointSigned("02-phase1-chain-0000") + head0 := "sha256:" + strings.Repeat("1", 64) + participant := "participant-01" + receiptAttempt := strings.Repeat("a", 32) + candidateAttempt := strings.Repeat("b", 32) + + cp0 := Checkpoint{ + Schema: CheckpointSchema, + Workflow: StorageFirstWorkflowV1, + CeremonyID: "sha256:" + strings.Repeat("c", 64), + Definition: definition, + AssurancePolicy: &AssurancePolicy{PublicWitnessesPerPhase: 1, MirrorsPerAcceptedHead: 1, PassingCeremonyAudits: 1}, + RelayReleaseID: "role-images-7ba406f", + Sequence: 0, + Transition: CheckpointTransition{Kind: CheckpointInitial}, + Phase1: CheckpointPhaseState{ + Phase: Phase1, AcceptedCount: 0, HeadRecordID: head0, + HeadPayload: genesis, Chain: chain0, + }, + AcceptedArtifacts: checkpointArtifacts(definition.Record, definition.Signature, genesis, chain0.Record, chain0.Signature), + Submissions: []CheckpointSubmissionSlot{}, + } + if err := cp0.Validate(); err != nil { + t.Fatalf("cp0: %v", err) + } + + handoff := checkpointSigned("10-outbound-handoff") + cp0Ref := checkpointReference(t, cp0, "0000") + cp1 := cloneCheckpoint(t, cp0) + cp1.Sequence = 1 + cp1.PreviousCheckpoint = &cp0Ref + cp1.Transition = CheckpointTransition{ + Kind: CheckpointPhase1OutboundPublished, Phase: Phase1, Index: 1, + ParticipantID: participant, AttemptID: receiptAttempt, Record: &handoff, + } + cp1.AcceptedArtifacts = appendCheckpointArtifacts(cp1.AcceptedArtifacts, handoff.Record, handoff.Signature) + cp1.Submissions = []CheckpointSubmissionSlot{{ + Kind: CheckpointSubmissionReceipt, Phase: Phase1, Index: 1, + IdentityID: participant, AttemptID: receiptAttempt, + ManifestKey: "submissions/receipt/" + receiptAttempt + "/manifest.json", + BasisCheckpointSHA256: cp0Ref.Record.Digest.SHA256, ParentHeadID: head0, + Status: CheckpointSubmissionAllocated, + }} + + receipt := checkpointSigned("20-receipt-envelope") + receiptAck := checkpointSigned("21-receipt-acknowledgement") + receiptManifest := checkpointArtifact("22-receipt-manifest.json", "receipt manifest") + receiptPayload := checkpointArtifact("23-receipt.json", "receipt payload") + receiptEvidence := checkpointArtifacts(receiptManifest, receiptPayload) + cp1Ref := checkpointReference(t, cp1, "0001") + cp2 := cloneCheckpoint(t, cp1) + cp2.Sequence = 2 + cp2.PreviousCheckpoint = &cp1Ref + cp2.Transition = CheckpointTransition{ + Kind: CheckpointPhase1ReceiptAccepted, Phase: Phase1, Index: 1, + ParticipantID: participant, AttemptID: receiptAttempt, NextAttemptID: candidateAttempt, + Record: &receipt, Acknowledgement: &receiptAck, Evidence: receiptEvidence, + } + cp2.AcceptedArtifacts = appendCheckpointArtifacts(cp2.AcceptedArtifacts, receipt.Record, receipt.Signature, receiptAck.Record, receiptAck.Signature, receiptManifest, receiptPayload) + cp2.Submissions[0].Status = CheckpointSubmissionAccepted + cp2.Submissions[0].Acknowledgement = &receiptAck + cp2.Submissions = append(cp2.Submissions, CheckpointSubmissionSlot{ + Kind: CheckpointSubmissionCandidate, Phase: Phase1, Index: 1, + IdentityID: participant, AttemptID: candidateAttempt, + ManifestKey: "submissions/candidate/" + candidateAttempt + "/manifest.json", + BasisCheckpointSHA256: cp1Ref.Record.Digest.SHA256, ParentHeadID: head0, + Status: CheckpointSubmissionAllocated, + }) + + candidate := checkpointSigned("30-candidate-envelope") + candidateAck := checkpointSigned("31-candidate-acknowledgement") + payload1 := checkpointArtifact("32-phase1-contribution-0001.bin", "contribution") + chain1 := checkpointSigned("33-phase1-chain-0001") + candidateManifest := checkpointArtifact("34-candidate-manifest.json", "candidate manifest") + candidatePayloads := checkpointArtifacts( + checkpointArtifact("35-attestation.json", "attestation"), + checkpointArtifact("36-attestation.sig", "attestation signature"), + checkpointArtifact("37-cleanup.json", "cleanup"), + checkpointArtifact("38-cleanup.sig", "cleanup signature"), + payload1, + ) + candidateEvidence := checkpointArtifacts(append([]ArtifactRef{candidateManifest}, candidatePayloads...)...) + cp2Ref := checkpointReference(t, cp2, "0002") + cp3 := cloneCheckpoint(t, cp2) + cp3.Sequence = 3 + cp3.PreviousCheckpoint = &cp2Ref + cp3.Transition = CheckpointTransition{ + Kind: CheckpointPhase1CandidateAccepted, Phase: Phase1, Index: 1, + ParticipantID: participant, AttemptID: candidateAttempt, + Record: &candidate, Acknowledgement: &candidateAck, Evidence: candidateEvidence, + } + cp3.Phase1 = CheckpointPhaseState{ + Phase: Phase1, AcceptedCount: 1, + HeadRecordID: "sha256:" + strings.Repeat("2", 64), + HeadPayload: payload1, Chain: chain1, + } + cp3.AcceptedArtifacts = appendCheckpointArtifacts(cp3.AcceptedArtifacts, + candidate.Record, candidate.Signature, candidateAck.Record, candidateAck.Signature, + candidateManifest, chain1.Record, chain1.Signature, + ) + cp3.AcceptedArtifacts = appendCheckpointArtifacts(cp3.AcceptedArtifacts, candidatePayloads...) + cp3.Submissions[1].Status = CheckpointSubmissionAccepted + cp3.Submissions[1].Acknowledgement = &candidateAck + + return [4]Checkpoint{cp0, cp1, cp2, cp3} +} + +func TestCheckpointPhase1LegalSequence(t *testing.T) { + checkpoints := phase1CheckpointSequence(t) + for index := range checkpoints { + if err := checkpoints[index].Validate(); err != nil { + t.Fatalf("checkpoint %d: %v", index, err) + } + raw, err := MarshalCanonical(checkpoints[index]) + if err != nil { + t.Fatalf("checkpoint %d canonical marshal: %v", index, err) + } + var decoded Checkpoint + if err := UnmarshalCanonical(raw, &decoded); err != nil { + t.Fatalf("checkpoint %d canonical round trip: %v", index, err) + } + } + for index := 1; index < len(checkpoints); index++ { + if err := ValidateCheckpointTransition(checkpoints[index-1], checkpoints[index]); err != nil { + t.Fatalf("transition %d: %v", index, err) + } + } +} + +func TestCheckpointRejectsMislabeledPhase1State(t *testing.T) { + sequence := phase1CheckpointSequence(t) + for index := range sequence { + changed := cloneCheckpoint(t, sequence[index]) + changed.Phase1.Phase = Phase2 + if err := changed.Validate(); err == nil || !strings.Contains(err.Error(), "phase1 state must identify phase1") { + t.Fatalf("checkpoint %d mislabeled phase1 err=%v", index, err) + } + } +} + +func TestCheckpointPhase1ClosureIsOneWayAndExact(t *testing.T) { + sequence := phase1CheckpointSequence(t) + previous := sequence[3] + next := phase1ClosedCheckpoint(t, previous) + if err := ValidateCheckpointTransition(previous, next); err != nil { + t.Fatalf("valid phase1 closure: %v", err) + } + + mutations := []struct { + name string + mutate func(*Checkpoint) + }{ + {"changed head", func(c *Checkpoint) { c.Phase1.HeadRecordID = "sha256:" + strings.Repeat("9", 64) }}, + {"changed slots", func(c *Checkpoint) { c.Submissions = c.Submissions[:1] }}, + {"different committed closure", func(c *Checkpoint) { + c.Phase1Closure = &SignedArtifactRefs{Record: checkpointArtifact("other.json", "other"), Signature: checkpointArtifact("other.sig", "other sig")} + }}, + {"unexpected artifact", func(c *Checkpoint) { + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts, checkpointArtifact("unexpected.json", "unexpected")) + }}, + } + for _, test := range mutations { + t.Run(test.name, func(t *testing.T) { + changed := cloneCheckpoint(t, next) + test.mutate(&changed) + if err := ValidateCheckpointTransition(previous, changed); err == nil { + t.Fatal("mutated closure unexpectedly accepted") + } + }) + } + + afterClose := cloneCheckpoint(t, next) + illegalOutbound := checkpointSigned("41-illegal-outbound") + afterClose.Transition = CheckpointTransition{ + Kind: CheckpointPhase1OutboundPublished, Phase: Phase1, Index: 2, + ParticipantID: "participant-02", AttemptID: strings.Repeat("e", 32), Record: &illegalOutbound, + } + if err := validateOutboundTransition(next, afterClose, Phase1); err == nil || !strings.Contains(err.Error(), "after closure") { + t.Fatalf("phase1 turn after closure err=%v", err) + } + + for _, index := range []int{1, 2} { + t.Run(fmt.Sprintf("allocated slot at checkpoint %d", index), func(t *testing.T) { + pending := sequence[index] + closure := phase1ClosedCheckpoint(t, pending) + if err := ValidateCheckpointTransition(pending, closure); err == nil || !strings.Contains(err.Error(), "still allocated") { + t.Fatalf("closure with allocated submission err=%v", err) + } + }) + } + + t.Run("second outbound while turn pending", func(t *testing.T) { + pending := sequence[1] + duplicate := cloneCheckpoint(t, pending) + duplicate.Sequence++ + previousRef := checkpointReference(t, pending, "duplicate-outbound-parent") + duplicate.PreviousCheckpoint = &previousRef + record := checkpointSigned("duplicate-outbound") + duplicate.Transition = CheckpointTransition{ + Kind: CheckpointPhase1OutboundPublished, Phase: Phase1, Index: 1, + ParticipantID: "participant-01", AttemptID: strings.Repeat("f", 32), Record: &record, + } + duplicate.AcceptedArtifacts = appendCheckpointArtifacts(duplicate.AcceptedArtifacts, record.Record, record.Signature) + duplicate.Submissions = append(duplicate.Submissions, CheckpointSubmissionSlot{ + Kind: CheckpointSubmissionReceipt, Phase: Phase1, Index: 1, + IdentityID: "participant-01", AttemptID: strings.Repeat("f", 32), + ManifestKey: "submissions/duplicate/manifest.json", BasisCheckpointSHA256: previousRef.Record.Digest.SHA256, + ParentHeadID: pending.Phase1.HeadRecordID, Status: CheckpointSubmissionAllocated, + }) + if err := ValidateCheckpointTransition(pending, duplicate); err == nil || !strings.Contains(err.Error(), "another submission attempt") { + t.Fatalf("duplicate outbound err=%v", err) + } + }) +} + +func phase1ClosedCheckpoint(t *testing.T, previous Checkpoint) Checkpoint { + t.Helper() + closure := checkpointSigned("40-phase1-closure") + previousRef := checkpointReference(t, previous, "0003") + next := cloneCheckpoint(t, previous) + next.Sequence++ + next.PreviousCheckpoint = &previousRef + next.Transition = CheckpointTransition{Kind: CheckpointPhase1Closed, Phase: Phase1, Record: &closure} + next.Phase1Closure = &closure + next.AcceptedArtifacts = appendCheckpointArtifacts(next.AcceptedArtifacts, closure.Record, closure.Signature) + return next +} + +func TestCheckpointPhase1BeaconRequiresExactClosureAndRawResponse(t *testing.T) { + previous := phase1ClosedCheckpoint(t, phase1CheckpointSequence(t)[3]) + beacon := checkpointSigned("50-phase1-beacon") + raw := checkpointArtifact("51-phase1-raw-response.bin", "raw beacon") + previousRef := checkpointReference(t, previous, "0004") + next := cloneCheckpoint(t, previous) + next.Sequence++ + next.PreviousCheckpoint = &previousRef + next.Transition = CheckpointTransition{Kind: CheckpointPhase1BeaconRecorded, Phase: Phase1, Record: &beacon, Evidence: []ArtifactRef{raw}} + next.Phase1Beacon = &beacon + next.AcceptedArtifacts = appendCheckpointArtifacts(next.AcceptedArtifacts, beacon.Record, beacon.Signature, raw) + if err := ValidateCheckpointTransition(previous, next); err != nil { + t.Fatalf("valid phase1 beacon: %v", err) + } + + mutations := []struct { + name string + mutate func(*Checkpoint) + }{ + {"changed closure", func(c *Checkpoint) { + c.Phase1Closure = func() *SignedArtifactRefs { value := checkpointSigned("other-closure"); return &value }() + }}, + {"missing raw response", func(c *Checkpoint) { c.Transition.Evidence = nil }}, + {"changed slots", func(c *Checkpoint) { c.Submissions = c.Submissions[:1] }}, + {"unexpected artifact", func(c *Checkpoint) { + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts, checkpointArtifact("unexpected-beacon.json", "unexpected")) + }}, + } + for _, test := range mutations { + t.Run(test.name, func(t *testing.T) { + changed := cloneCheckpoint(t, next) + test.mutate(&changed) + if err := ValidateCheckpointTransition(previous, changed); err == nil { + t.Fatal("mutated beacon unexpectedly accepted") + } + }) + } +} + +func TestCheckpointPhase1SealRequiresExactBeaconAndCommons(t *testing.T) { + base := phase1ClosedCheckpoint(t, phase1CheckpointSequence(t)[3]) + beaconRefs := checkpointSigned("50-phase1-beacon") + raw := checkpointArtifact("51-phase1-raw-response.json", "raw beacon") + beacon := cloneCheckpoint(t, base) + beacon.Sequence++ + parent := checkpointReference(t, base, "0004") + beacon.PreviousCheckpoint = &parent + beacon.Transition = CheckpointTransition{Kind: CheckpointPhase1BeaconRecorded, Phase: Phase1, Record: &beaconRefs, Evidence: []ArtifactRef{raw}} + beacon.Phase1Beacon = &beaconRefs + beacon.AcceptedArtifacts = appendCheckpointArtifacts(beacon.AcceptedArtifacts, beaconRefs.Record, beaconRefs.Signature, raw) + + sealRefs := checkpointSigned("60-phase1-seal") + commons := checkpointArtifact("phase1/sealed/commons.bin", "derived commons") + sealed := cloneCheckpoint(t, beacon) + sealed.Sequence++ + sealParent := checkpointReference(t, beacon, "0005") + sealed.PreviousCheckpoint = &sealParent + sealed.Transition = CheckpointTransition{Kind: CheckpointPhase1Sealed, Phase: Phase1, Record: &sealRefs, Evidence: []ArtifactRef{commons}} + sealed.Phase1Seal = &sealRefs + sealed.AcceptedArtifacts = appendCheckpointArtifacts(sealed.AcceptedArtifacts, sealRefs.Record, sealRefs.Signature, commons) + if err := ValidateCheckpointTransition(beacon, sealed); err != nil { + t.Fatalf("valid phase1 seal: %v", err) + } + + mutations := []struct { + name string + mutate func(*Checkpoint) + }{ + {"changed beacon", func(c *Checkpoint) { + c.Phase1Beacon = func() *SignedArtifactRefs { value := checkpointSigned("other-beacon"); return &value }() + }}, + {"missing commons", func(c *Checkpoint) { c.Transition.Evidence = nil }}, + {"changed head", func(c *Checkpoint) { c.Phase1.HeadRecordID = "sha256:" + strings.Repeat("8", 64) }}, + {"unexpected artifact", func(c *Checkpoint) { + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts, checkpointArtifact("unexpected-seal.json", "unexpected")) + }}, + } + for _, test := range mutations { + t.Run(test.name, func(t *testing.T) { + changed := cloneCheckpoint(t, sealed) + test.mutate(&changed) + if err := ValidateCheckpointTransition(beacon, changed); err == nil { + t.Fatal("mutated phase1 seal unexpectedly accepted") + } + }) + } +} + +func TestPhase1SealRejectsUnverifiedExtraOutputs(t *testing.T) { + seal := SealRecord{Phase: Phase1, Outputs: []ArtifactRef{ + checkpointArtifact("phase1/sealed/commons.bin", "commons"), + checkpointArtifact("phase1/sealed/unverified.bin", "unverified"), + }} + if _, err := phase1CommonsOutput(seal); err == nil || !strings.Contains(err.Error(), "exactly one") { + t.Fatalf("extra Phase 1 seal output err=%v", err) + } +} + +func TestCheckpointPhase2InitializationRequiresExactGenesis(t *testing.T) { + base := phase1ClosedCheckpoint(t, phase1CheckpointSequence(t)[3]) + beaconRefs := checkpointSigned("phase1/beacon/record") + raw := checkpointArtifact("phase1/beacon/raw-response.bin", "raw") + beacon := cloneCheckpoint(t, base) + beacon.Sequence++ + beaconParent := checkpointReference(t, base, "phase1-closed") + beacon.PreviousCheckpoint = &beaconParent + beacon.Transition = CheckpointTransition{Kind: CheckpointPhase1BeaconRecorded, Phase: Phase1, Record: &beaconRefs, Evidence: []ArtifactRef{raw}} + beacon.Phase1Beacon = &beaconRefs + beacon.AcceptedArtifacts = appendCheckpointArtifacts(beacon.AcceptedArtifacts, beaconRefs.Record, beaconRefs.Signature, raw) + sealRefs := checkpointSigned("phase1/sealed/seal") + commons := checkpointArtifact("phase1/sealed/commons.bin", "commons") + sealed := cloneCheckpoint(t, beacon) + sealed.Sequence++ + sealParent := checkpointReference(t, beacon, "phase1-beacon") + sealed.PreviousCheckpoint = &sealParent + sealed.Transition = CheckpointTransition{Kind: CheckpointPhase1Sealed, Phase: Phase1, Record: &sealRefs, Evidence: []ArtifactRef{commons}} + sealed.Phase1Seal = &sealRefs + sealed.AcceptedArtifacts = appendCheckpointArtifacts(sealed.AcceptedArtifacts, sealRefs.Record, sealRefs.Signature, commons) + + chain := checkpointSigned("phase2/chain-0000") + genesis := checkpointArtifact("phase2/genesis.bin", "phase2 genesis") + next := cloneCheckpoint(t, sealed) + next.Sequence++ + phase2Parent := checkpointReference(t, sealed, "phase1-sealed") + next.PreviousCheckpoint = &phase2Parent + next.Transition = CheckpointTransition{Kind: CheckpointPhase2Initialized, Phase: Phase2, Record: &chain, Evidence: []ArtifactRef{genesis}} + phase2 := CheckpointPhaseState{Phase: Phase2, HeadRecordID: "sha256:" + strings.Repeat("7", 64), HeadPayload: genesis, Chain: chain} + next.Phase2 = &phase2 + next.AcceptedArtifacts = appendCheckpointArtifacts(next.AcceptedArtifacts, chain.Record, chain.Signature, genesis) + if err := ValidateCheckpointTransition(sealed, next); err != nil { + t.Fatalf("valid phase2 initialization: %v", err) + } + changed := cloneCheckpoint(t, next) + changed.Phase2.HeadPayload = checkpointArtifact("phase2/other.bin", "other") + if err := ValidateCheckpointTransition(sealed, changed); err == nil { + t.Fatal("changed phase2 genesis unexpectedly accepted") + } + changed = cloneCheckpoint(t, next) + changed.AcceptedArtifacts = appendCheckpointArtifacts(changed.AcceptedArtifacts, checkpointArtifact("phase2/unexpected.bin", "unexpected")) + if err := ValidateCheckpointTransition(sealed, changed); err == nil { + t.Fatal("unexpected phase2 artifact accepted") + } + unsealed := cloneCheckpoint(t, sealed) + unsealed.Phase1Seal = nil + if err := ValidateCheckpointTransition(unsealed, next); err == nil { + t.Fatal("phase2 initialization from unsealed parent accepted") + } + repeated := cloneCheckpoint(t, next) + repeated.Sequence++ + repeatedParent := checkpointReference(t, next, "phase2-initialized") + repeated.PreviousCheckpoint = &repeatedParent + if err := ValidateCheckpointTransition(next, repeated); err == nil { + t.Fatal("repeated phase2 initialization accepted") + } +} + +func TestCheckpointPhase2ParticipantTurnSequence(t *testing.T) { + base := phase1ClosedCheckpoint(t, phase1CheckpointSequence(t)[3]) + beaconRefs := checkpointSigned("phase1/beacon/record") + raw := checkpointArtifact("phase1/beacon/raw-response.bin", "raw") + beacon := cloneCheckpoint(t, base) + beacon.Sequence++ + parent := checkpointReference(t, base, "p2-base") + beacon.PreviousCheckpoint = &parent + beacon.Transition = CheckpointTransition{Kind: CheckpointPhase1BeaconRecorded, Phase: Phase1, Record: &beaconRefs, Evidence: []ArtifactRef{raw}} + beacon.Phase1Beacon = &beaconRefs + beacon.AcceptedArtifacts = appendCheckpointArtifacts(beacon.AcceptedArtifacts, beaconRefs.Record, beaconRefs.Signature, raw) + sealRefs := checkpointSigned("phase1/sealed/seal") + commons := checkpointArtifact("phase1/sealed/commons.bin", "commons") + sealed := cloneCheckpoint(t, beacon) + sealed.Sequence++ + sealParent := checkpointReference(t, beacon, "p2-beacon") + sealed.PreviousCheckpoint = &sealParent + sealed.Transition = CheckpointTransition{Kind: CheckpointPhase1Sealed, Phase: Phase1, Record: &sealRefs, Evidence: []ArtifactRef{commons}} + sealed.Phase1Seal = &sealRefs + sealed.AcceptedArtifacts = appendCheckpointArtifacts(sealed.AcceptedArtifacts, sealRefs.Record, sealRefs.Signature, commons) + chain0 := checkpointSigned("phase2/chain-0000") + genesis := checkpointArtifact("phase2/genesis.bin", "phase2 genesis") + initialized := cloneCheckpoint(t, sealed) + initialized.Sequence++ + initializedParent := checkpointReference(t, sealed, "p2-sealed") + initialized.PreviousCheckpoint = &initializedParent + initialized.Transition = CheckpointTransition{Kind: CheckpointPhase2Initialized, Phase: Phase2, Record: &chain0, Evidence: []ArtifactRef{genesis}} + initialized.Phase2 = &CheckpointPhaseState{Phase: Phase2, HeadRecordID: "sha256:" + strings.Repeat("7", 64), HeadPayload: genesis, Chain: chain0} + initialized.AcceptedArtifacts = appendCheckpointArtifacts(initialized.AcceptedArtifacts, chain0.Record, chain0.Signature, genesis) + + participant := "participant-01" + receiptAttempt, candidateAttempt := strings.Repeat("c", 32), strings.Repeat("d", 32) + handoff := checkpointSigned("phase2/handoff") + outbound := cloneCheckpoint(t, initialized) + outbound.Sequence++ + outboundParent := checkpointReference(t, initialized, "p2-initialized") + outbound.PreviousCheckpoint = &outboundParent + outbound.Transition = CheckpointTransition{Kind: CheckpointPhase2OutboundPublished, Phase: Phase2, Index: 1, ParticipantID: participant, AttemptID: receiptAttempt, Record: &handoff} + outbound.AcceptedArtifacts = appendCheckpointArtifacts(outbound.AcceptedArtifacts, handoff.Record, handoff.Signature) + outbound.Submissions = append(outbound.Submissions, CheckpointSubmissionSlot{Kind: CheckpointSubmissionReceipt, Phase: Phase2, Index: 1, IdentityID: participant, AttemptID: receiptAttempt, ManifestKey: "submissions/phase2-receipt/manifest.json", BasisCheckpointSHA256: outboundParent.Record.Digest.SHA256, ParentHeadID: initialized.Phase2.HeadRecordID, Status: CheckpointSubmissionAllocated}) + + receiptRecord, receiptAck := checkpointSigned("phase2/receipt"), checkpointSigned("phase2/receipt-ack") + receiptManifest := checkpointArtifact("submissions/phase2-receipt/manifest.json", "manifest") + receiptPayload := checkpointArtifact("submissions/phase2-receipt/receipt.json", "receipt") + receipt := cloneCheckpoint(t, outbound) + receipt.Sequence++ + receiptParent := checkpointReference(t, outbound, "p2-outbound") + receipt.PreviousCheckpoint = &receiptParent + receipt.Transition = CheckpointTransition{Kind: CheckpointPhase2ReceiptAccepted, Phase: Phase2, Index: 1, ParticipantID: participant, AttemptID: receiptAttempt, NextAttemptID: candidateAttempt, Record: &receiptRecord, Acknowledgement: &receiptAck, Evidence: checkpointArtifacts(receiptManifest, receiptPayload)} + receipt.AcceptedArtifacts = appendCheckpointArtifacts(receipt.AcceptedArtifacts, receiptRecord.Record, receiptRecord.Signature, receiptAck.Record, receiptAck.Signature, receiptManifest, receiptPayload) + phase2ReceiptIndex := len(receipt.Submissions) - 1 + receipt.Submissions[phase2ReceiptIndex].Status = CheckpointSubmissionAccepted + receipt.Submissions[phase2ReceiptIndex].Acknowledgement = &receiptAck + receipt.Submissions = append(receipt.Submissions, CheckpointSubmissionSlot{Kind: CheckpointSubmissionCandidate, Phase: Phase2, Index: 1, IdentityID: participant, AttemptID: candidateAttempt, ManifestKey: "submissions/phase2-candidate/manifest.json", BasisCheckpointSHA256: receiptParent.Record.Digest.SHA256, ParentHeadID: initialized.Phase2.HeadRecordID, Status: CheckpointSubmissionAllocated}) + + candidateRecord, candidateAck := checkpointSigned("phase2/candidate"), checkpointSigned("phase2/candidate-ack") + candidateManifest := checkpointArtifact("submissions/phase2-candidate/manifest.json", "manifest") + payload := checkpointArtifact("phase2/contributions/0001/contribution.bin", "contribution") + candidateEvidence := checkpointArtifacts(candidateManifest, payload) + candidate := cloneCheckpoint(t, receipt) + candidate.Sequence++ + candidateParent := checkpointReference(t, receipt, "p2-receipt") + candidate.PreviousCheckpoint = &candidateParent + candidate.Transition = CheckpointTransition{Kind: CheckpointPhase2CandidateAccepted, Phase: Phase2, Index: 1, ParticipantID: participant, AttemptID: candidateAttempt, Record: &candidateRecord, Acknowledgement: &candidateAck, Evidence: candidateEvidence} + chain1 := checkpointSigned("phase2/chain-0001") + candidate.Phase2 = &CheckpointPhaseState{Phase: Phase2, AcceptedCount: 1, HeadRecordID: "sha256:" + strings.Repeat("8", 64), HeadPayload: payload, Chain: chain1} + candidate.AcceptedArtifacts = appendCheckpointArtifacts(candidate.AcceptedArtifacts, candidateRecord.Record, candidateRecord.Signature, candidateAck.Record, candidateAck.Signature, candidateManifest, payload, chain1.Record, chain1.Signature) + phase2CandidateIndex := len(candidate.Submissions) - 1 + candidate.Submissions[phase2CandidateIndex].Status = CheckpointSubmissionAccepted + candidate.Submissions[phase2CandidateIndex].Acknowledgement = &candidateAck + + for _, edge := range [][2]Checkpoint{{initialized, outbound}, {outbound, receipt}, {receipt, candidate}} { + if err := ValidateCheckpointTransition(edge[0], edge[1]); err != nil { + t.Fatalf("valid phase2 turn edge %s: %v", edge[1].Transition.Kind, err) + } + for _, field := range []string{"closure", "beacon", "seal"} { + changed := cloneCheckpoint(t, edge[1]) + replacement := edge[0].Definition + switch field { + case "closure": + changed.Phase1Closure = &replacement + case "beacon": + changed.Phase1Beacon = &replacement + case "seal": + changed.Phase1Seal = &replacement + } + if err := ValidateCheckpointTransition(edge[0], changed); err == nil { + t.Fatalf("%s edge accepted changed phase1 %s", edge[1].Transition.Kind, field) + } + } + } + + closureRefs := checkpointSigned("phase2/closure/record") + closed := cloneCheckpoint(t, candidate) + closed.Sequence++ + closedParent := checkpointReference(t, candidate, "p2-candidate") + closed.PreviousCheckpoint = &closedParent + closed.Transition = CheckpointTransition{Kind: CheckpointPhase2Closed, Phase: Phase2, Record: &closureRefs} + closed.Phase2Closure = &closureRefs + closed.AcceptedArtifacts = appendCheckpointArtifacts(closed.AcceptedArtifacts, closureRefs.Record, closureRefs.Signature) + if err := ValidateCheckpointTransition(candidate, closed); err != nil { + t.Fatalf("valid phase2 closure: %v", err) + } + + phase2BeaconRefs := checkpointSigned("phase2/beacon/record") + phase2Raw := checkpointArtifact("phase2/beacon/raw-response.bin", "phase2 raw") + beaconed := cloneCheckpoint(t, closed) + beaconed.Sequence++ + beaconParent := checkpointReference(t, closed, "p2-closed") + beaconed.PreviousCheckpoint = &beaconParent + beaconed.Transition = CheckpointTransition{Kind: CheckpointPhase2BeaconRecorded, Phase: Phase2, Record: &phase2BeaconRefs, Evidence: []ArtifactRef{phase2Raw}} + beaconed.Phase2Beacon = &phase2BeaconRefs + beaconed.AcceptedArtifacts = appendCheckpointArtifacts(beaconed.AcceptedArtifacts, phase2BeaconRefs.Record, phase2BeaconRefs.Signature, phase2Raw) + if err := ValidateCheckpointTransition(closed, beaconed); err != nil { + t.Fatalf("valid phase2 beacon: %v", err) + } + finalRefs := checkpointSigned("final/candidate/candidate") + finalEvidence := checkpointArtifacts( + checkpointArtifact("final/candidate/candidate-checksums.sha256", "checksums"), + checkpointArtifact("final/candidate/ownership.pk", "pk"), + ) + finalized := cloneCheckpoint(t, beaconed) + finalized.Sequence++ + finalParent := checkpointReference(t, beaconed, "p2-beaconed") + finalized.PreviousCheckpoint = &finalParent + finalized.Transition = CheckpointTransition{Kind: CheckpointFinalCandidateRecorded, Record: &finalRefs, Evidence: finalEvidence} + finalized.FinalCandidate = &finalRefs + finalized.AcceptedArtifacts = appendCheckpointArtifacts(finalized.AcceptedArtifacts, finalRefs.Record, finalRefs.Signature, finalEvidence[0], finalEvidence[1]) + if err := ValidateCheckpointTransition(beaconed, finalized); err != nil { + t.Fatalf("valid final candidate: %v", err) + } + releaseRefs := checkpointSigned("final/release/manifest") + releaseEvidence := checkpointArtifacts(checkpointArtifact("final/release/checksums.sha256", "release checksums")) + released := cloneCheckpoint(t, finalized) + released.Sequence++ + releasedParent := checkpointReference(t, finalized, "finalized") + released.PreviousCheckpoint = &releasedParent + released.Transition = CheckpointTransition{Kind: CheckpointFinalReleaseRecorded, Record: &releaseRefs, Evidence: releaseEvidence} + released.FinalRelease = &releaseRefs + released.AcceptedArtifacts = appendCheckpointArtifacts(released.AcceptedArtifacts, releaseRefs.Record, releaseRefs.Signature, releaseEvidence[0]) + if err := ValidateCheckpointTransition(finalized, released); err != nil { + t.Fatalf("valid final release: %v", err) + } + + allocated := cloneCheckpoint(t, candidate) + allocated.Submissions[phase2CandidateIndex].Status = CheckpointSubmissionAllocated + if err := ValidateCheckpointTransition(allocated, closed); err == nil { + t.Fatal("phase2 closed with an allocated submission") + } + repeatedClose := cloneCheckpoint(t, closed) + repeatedClose.Sequence++ + repeatedCloseParent := checkpointReference(t, closed, "p2-closed-again") + repeatedClose.PreviousCheckpoint = &repeatedCloseParent + if err := ValidateCheckpointTransition(closed, repeatedClose); err == nil { + t.Fatal("phase2 closed twice") + } + withoutClosure := cloneCheckpoint(t, beaconed) + withoutClosure.Phase2Closure = nil + if err := ValidateCheckpointTransition(candidate, withoutClosure); err == nil { + t.Fatal("phase2 beacon accepted without closure") + } + repeatedBeacon := cloneCheckpoint(t, beaconed) + repeatedBeacon.Sequence++ + repeatedBeaconParent := checkpointReference(t, beaconed, "p2-beacon-again") + repeatedBeacon.PreviousCheckpoint = &repeatedBeaconParent + if err := ValidateCheckpointTransition(beaconed, repeatedBeacon); err == nil { + t.Fatal("phase2 beacon accepted twice") + } + changedPhase1 := cloneCheckpoint(t, closed) + replacement := closed.Definition + changedPhase1.Phase1Seal = &replacement + if err := ValidateCheckpointTransition(candidate, changedPhase1); err == nil { + t.Fatal("phase2 closure accepted changed phase1 lifecycle") + } + unexpected := cloneCheckpoint(t, beaconed) + unexpected.AcceptedArtifacts = appendCheckpointArtifacts(unexpected.AcceptedArtifacts, checkpointArtifact("phase2/unexpected.bin", "unexpected")) + if err := ValidateCheckpointTransition(closed, unexpected); err == nil { + t.Fatal("phase2 beacon accepted an unexpected artifact") + } + tooEarly := cloneCheckpoint(t, finalized) + tooEarly.Phase2Beacon = nil + if err := ValidateCheckpointTransition(closed, tooEarly); err == nil { + t.Fatal("final candidate accepted before phase2 beacon") + } + repeatedFinal := cloneCheckpoint(t, finalized) + repeatedFinal.Sequence++ + repeatedFinalParent := checkpointReference(t, finalized, "final-again") + repeatedFinal.PreviousCheckpoint = &repeatedFinalParent + if err := ValidateCheckpointTransition(finalized, repeatedFinal); err == nil { + t.Fatal("final candidate accepted twice") + } + extraFinal := cloneCheckpoint(t, finalized) + extraFinal.AcceptedArtifacts = appendCheckpointArtifacts(extraFinal.AcceptedArtifacts, checkpointArtifact("final/candidate/extra.bin", "extra")) + if err := ValidateCheckpointTransition(beaconed, extraFinal); err == nil { + t.Fatal("final candidate accepted an extra artifact") + } + repeatedRelease := cloneCheckpoint(t, released) + repeatedRelease.Sequence++ + repeatedReleaseParent := checkpointReference(t, released, "release-again") + repeatedRelease.PreviousCheckpoint = &repeatedReleaseParent + if err := ValidateCheckpointTransition(released, repeatedRelease); err == nil { + t.Fatal("final release accepted twice") + } + changedCandidate := cloneCheckpoint(t, released) + replacementCandidate := checkpointSigned("final/candidate/replacement") + changedCandidate.FinalCandidate = &replacementCandidate + if err := ValidateCheckpointTransition(finalized, changedCandidate); err == nil { + t.Fatal("final release accepted a changed final candidate") + } +} + +func TestCheckpointOldSchemasRejectPhase2TurnTransition(t *testing.T) { + for _, schema := range []string{CheckpointSchemaV1, CheckpointSchemaV2} { + checkpoint := phase1CheckpointSequence(t)[0] + checkpoint.Schema = schema + checkpoint.Transition = CheckpointTransition{Kind: CheckpointPhase2OutboundPublished, Phase: Phase2, Index: 1, ParticipantID: "participant-01", AttemptID: strings.Repeat("e", 32), Record: func() *SignedArtifactRefs { value := checkpointSigned("phase2-outbound"); return &value }()} + checkpoint.AcceptedArtifacts = appendCheckpointArtifacts(checkpoint.AcceptedArtifacts, checkpoint.Transition.Record.Record, checkpoint.Transition.Record.Signature) + if schema == CheckpointSchemaV1 { + checkpoint.AssurancePolicy = nil + } + if err := checkpoint.Validate(); err == nil { + t.Fatalf("schema %s accepted phase2 turn without phase2 state", schema) + } + } + for _, schema := range []string{CheckpointSchemaV1, CheckpointSchemaV2} { + checkpoint := phase1CheckpointSequence(t)[0] + checkpoint.Schema = schema + if schema == CheckpointSchemaV1 { + checkpoint.AssurancePolicy = nil + } + checkpoint.Submissions = []CheckpointSubmissionSlot{{ + Kind: CheckpointSubmissionReceipt, Phase: Phase2, Index: 1, IdentityID: "participant-01", + AttemptID: strings.Repeat("f", 32), ManifestKey: "submissions/legacy/manifest.json", + BasisCheckpointSHA256: "sha256:" + strings.Repeat("1", 64), ParentHeadID: "sha256:" + strings.Repeat("2", 64), Status: CheckpointSubmissionAllocated, + }} + if err := checkpoint.Validate(); err == nil { + t.Fatalf("schema %s accepted phase2 submission slot", schema) + } + } + for _, schema := range []string{CheckpointSchemaV1, CheckpointSchemaV2} { + checkpoint := phase1CheckpointSequence(t)[0] + checkpoint.Schema = schema + if schema == CheckpointSchemaV1 { + checkpoint.AssurancePolicy = nil + } + finalRefs := checkpointSigned("final/candidate/candidate") + checkpoint.Transition = CheckpointTransition{Kind: CheckpointFinalCandidateRecorded, Record: &finalRefs} + checkpoint.FinalCandidate = &finalRefs + checkpoint.AcceptedArtifacts = appendCheckpointArtifacts(checkpoint.AcceptedArtifacts, finalRefs.Record, finalRefs.Signature) + if err := checkpoint.Validate(); err == nil { + t.Fatalf("schema %s accepted final-candidate state", schema) + } + } + for _, schema := range []string{CheckpointSchemaV1, CheckpointSchemaV2} { + checkpoint := phase1CheckpointSequence(t)[0] + checkpoint.Schema = schema + if schema == CheckpointSchemaV1 { + checkpoint.AssurancePolicy = nil + } + releaseRefs := checkpointSigned("final/release/manifest") + checkpoint.Transition = CheckpointTransition{Kind: CheckpointFinalReleaseRecorded, Record: &releaseRefs} + checkpoint.FinalRelease = &releaseRefs + checkpoint.AcceptedArtifacts = appendCheckpointArtifacts(checkpoint.AcceptedArtifacts, releaseRefs.Record, releaseRefs.Signature) + if err := checkpoint.Validate(); err == nil { + t.Fatalf("schema %s accepted final-release state", schema) + } + } +} + +func TestValidateCandidateReplayClaims(t *testing.T) { + phase1 := PhaseSummary{Phase: Phase1} + phase2 := PhaseSummary{Phase: Phase2} + timestamp := "2026-09-15T00:00:00Z" + candidate := CandidateMetadata{Phase1: phase1, Phase2: phase2, FinalizedAt: timestamp} + seal := SealRecord{SealedAt: timestamp} + report := VerificationReport{CheckedAt: timestamp} + if err := validateCandidateReplayClaims(candidate, phase1, phase2, seal, report); err != nil { + t.Fatalf("matching replay claims rejected: %v", err) + } + changed := candidate + changed.Phase1.ContributionCount++ + if err := validateCandidateReplayClaims(changed, phase1, phase2, seal, report); err == nil { + t.Fatal("changed Phase 1 summary accepted") + } + changed = candidate + changed.Phase2.ContributionCount++ + if err := validateCandidateReplayClaims(changed, phase1, phase2, seal, report); err == nil { + t.Fatal("changed Phase 2 summary accepted") + } + changed = candidate + changed.FinalizedAt = "2026-09-15T00:00:01Z" + if err := validateCandidateReplayClaims(changed, phase1, phase2, seal, report); err == nil { + t.Fatal("inconsistent candidate chronology accepted") + } +} + +func TestCheckpointBoundsCoverBothMaximumParticipantSchedules(t *testing.T) { + const turns = 2 * MaxParticipants + if MaxCheckpointAncestry < turns*3+10 { + t.Fatalf("ancestry bound %d cannot cover %d participant edges plus lifecycle", MaxCheckpointAncestry, turns*3) + } + if MaxCheckpointArtifacts < turns*21+10 { + t.Fatalf("artifact bound %d cannot cover %d participant artifacts plus lifecycle", MaxCheckpointArtifacts, turns*21) + } +} + +func TestCheckpointPredecessorIncludesFetchableSignatureReference(t *testing.T) { + checkpoints := phase1CheckpointSequence(t) + parent := checkpoints[1].PreviousCheckpoint + if parent == nil || parent.Record.Name == "" || parent.Signature.Name == "" || + parent.Record.Digest.SHA256 == "" || parent.Signature.Digest.SHA256 == "" { + t.Fatal("checkpoint predecessor does not provide fetchable record and signature references") + } + changed := cloneCheckpoint(t, checkpoints[1]) + changed.PreviousCheckpoint.Signature = ArtifactRef{} + if err := changed.Validate(); err == nil { + t.Fatal("checkpoint accepted a predecessor without a fetchable signature reference") + } +} + +func TestInitialCheckpointRejectsProgressOrSubmissionState(t *testing.T) { + cp0 := phase1CheckpointSequence(t)[0] + advanced := cloneCheckpoint(t, cp0) + advanced.Phase1.AcceptedCount = 1 + if err := advanced.Validate(); err == nil { + t.Fatal("initial checkpoint accepted contribution progress") + } + withSubmission := cloneCheckpoint(t, cp0) + withSubmission.Submissions = []CheckpointSubmissionSlot{{ + Kind: CheckpointSubmissionReceipt, Phase: Phase1, Index: 1, + IdentityID: "participant-01", AttemptID: strings.Repeat("a", 32), + ManifestKey: "submissions/receipt/manifest.json", + BasisCheckpointSHA256: "sha256:" + strings.Repeat("4", 64), + ParentHeadID: "sha256:" + strings.Repeat("5", 64), + Status: CheckpointSubmissionAllocated, + }} + if err := withSubmission.Validate(); err == nil { + t.Fatal("initial checkpoint accepted a pending submission") + } +} + +func TestCheckpointTransitionRejectsIndependentMutations(t *testing.T) { + valid := phase1CheckpointSequence(t) + cases := []struct { + name string + previous int + next int + mutate func(*Checkpoint) + }{ + {"wrong predecessor digest", 0, 1, func(c *Checkpoint) { c.PreviousCheckpoint.Record.Digest = NewDigest([]byte("other")) }}, + {"skipped sequence", 0, 1, func(c *Checkpoint) { c.Sequence++ }}, + {"changed release", 0, 1, func(c *Checkpoint) { c.RelayReleaseID = "role-images-other" }}, + {"outbound advanced head", 0, 1, func(c *Checkpoint) { c.Phase1.HeadRecordID = "sha256:" + strings.Repeat("9", 64) }}, + {"receipt accepted wrong attempt", 1, 2, func(c *Checkpoint) { c.Transition.AttemptID = strings.Repeat("d", 32) }}, + {"receipt omitted candidate slot", 1, 2, func(c *Checkpoint) { c.Submissions = c.Submissions[:1] }}, + {"receipt omitted fetchable evidence", 1, 2, func(c *Checkpoint) { + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts[:len(c.AcceptedArtifacts)-1]) + }}, + {"receipt evidence not declared", 1, 2, func(c *Checkpoint) { c.Transition.Evidence = c.Transition.Evidence[:1] }}, + {"candidate did not advance head", 2, 3, func(c *Checkpoint) { c.Phase1 = valid[2].Phase1 }}, + {"candidate advanced by two", 2, 3, func(c *Checkpoint) { c.Phase1.AcceptedCount = 2 }}, + {"candidate omitted fetchable evidence", 2, 3, func(c *Checkpoint) { + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts[:len(c.AcceptedArtifacts)-1]) + }}, + {"unexpected accepted artifact", 2, 3, func(c *Checkpoint) { + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts, checkpointArtifact("99-unexpected", "unexpected")) + }}, + } + for _, test := range cases { + t.Run(test.name, func(t *testing.T) { + next := cloneCheckpoint(t, valid[test.next]) + test.mutate(&next) + if err := ValidateCheckpointTransition(valid[test.previous], next); err == nil { + t.Fatal("mutated transition unexpectedly accepted") + } + }) + } + if err := ValidateCheckpointTransition(valid[0], valid[2]); err == nil { + t.Fatal("checkpoint sequence skipped cp1") + } +} + +func TestCheckpointRejectsReusedAttemptOrManifestAndNonportableNames(t *testing.T) { + cp1 := cloneCheckpoint(t, phase1CheckpointSequence(t)[1]) + base := cp1.Submissions[0] + other := base + other.Kind = CheckpointSubmissionCandidate + other.Index = 2 + other.IdentityID = "participant-2" + other.ManifestKey = "submissions/candidate/other/manifest.json" + cp1.Submissions = append(cp1.Submissions, other) + slices.SortFunc(cp1.Submissions, func(a, b CheckpointSubmissionSlot) int { return strings.Compare(a.key(), b.key()) }) + if err := cp1.Validate(); err == nil || !strings.Contains(err.Error(), "attempt IDs") { + t.Fatalf("reused attempt err=%v", err) + } + + cp1.Submissions[1].AttemptID = strings.Repeat("d", 32) + cp1.Submissions[1].ManifestKey = cp1.Submissions[0].ManifestKey + slices.SortFunc(cp1.Submissions, func(a, b CheckpointSubmissionSlot) int { return strings.Compare(a.key(), b.key()) }) + if err := cp1.Validate(); err == nil || !strings.Contains(err.Error(), "manifest keys") { + t.Fatalf("reused manifest err=%v", err) + } + + cp1.Submissions = cp1.Submissions[:1] + cp1.AcceptedArtifacts[0].Name = "Phase1/portable.json" + cp1.AcceptedArtifacts = checkpointArtifacts(cp1.AcceptedArtifacts...) + if err := cp1.Validate(); err == nil || !strings.Contains(err.Error(), "lowercase ASCII") { + t.Fatalf("nonportable name err=%v", err) + } +} + +func TestVerifySignedCheckpointBindsDefinitionAndCoordinator(t *testing.T) { + definition := adversarialDefinition(t) + definitionKey := adversarialPrivateKey(0x01) + definitionBytes, definitionSignature, err := SignRecord(definition, definition.Coordinator.KeyID, definitionKey) + if err != nil { + t.Fatalf("sign definition: %v", err) + } + cp := phase1CheckpointSequence(t)[0] + cp.CeremonyID = definition.CeremonyID + cp.AssurancePolicy = cloneAssurancePolicy(definition.AssurancePolicy) + cp.Definition = SignedArtifactRefs{ + Record: ArtifactRef{Name: "ceremony.json", Digest: NewDigest(definitionBytes)}, + Signature: ArtifactRef{Name: "ceremony.sig", Digest: NewDigest(definitionSignature)}, + } + cp.AcceptedArtifacts = appendCheckpointArtifacts(cp.AcceptedArtifacts[2:], cp.Definition.Record, cp.Definition.Signature) + cpBytes, cpSignature, err := SignRecord(cp, definition.Coordinator.KeyID, definitionKey) + if err != nil { + t.Fatalf("sign checkpoint: %v", err) + } + if _, err := VerifySignedCheckpoint(definition, definitionBytes, definitionSignature, cpBytes, cpSignature); err != nil { + t.Fatalf("verify checkpoint: %v", err) + } + wrongPolicy := cp + wrongPolicy.AssurancePolicy = cloneAssurancePolicy(cp.AssurancePolicy) + wrongPolicy.AssurancePolicy.MirrorsPerAcceptedHead = 0 + wrongBytes, wrongPolicySignature, err := SignRecord(wrongPolicy, definition.Coordinator.KeyID, definitionKey) + if err != nil { + t.Fatal(err) + } + if _, err := VerifySignedCheckpoint(definition, definitionBytes, definitionSignature, wrongBytes, wrongPolicySignature); err == nil { + t.Fatal("checkpoint accepted an assurance policy different from the signed definition") + } + + tamperedDefinition := append([]byte(nil), definitionBytes...) + tamperedDefinition[len(tamperedDefinition)-1] ^= 1 + if _, err := VerifySignedCheckpoint(definition, tamperedDefinition, definitionSignature, cpBytes, cpSignature); err == nil { + t.Fatal("checkpoint accepted tampered definition bytes") + } + otherKey := adversarialPrivateKey(0x22) + _, wrongSignature, err := SignRecord(cp, "other-key", otherKey) + if err != nil { + t.Fatalf("sign checkpoint with other key: %v", err) + } + if _, err := VerifySignedCheckpoint(definition, definitionBytes, definitionSignature, cpBytes, wrongSignature); err == nil { + t.Fatal("checkpoint accepted a non-coordinator signature") + } +} diff --git a/internal/mpcceremony/close_timing_test.go b/internal/mpcceremony/close_timing_test.go index d574fde5..f2f8383d 100644 --- a/internal/mpcceremony/close_timing_test.go +++ b/internal/mpcceremony/close_timing_test.go @@ -103,3 +103,40 @@ func TestValidateCloseCommitTimeReservesProductionWitnessWindow(t *testing.T) { t.Fatalf("production close without the witness window error = %v, want lead rejection", err) } } + +func TestValidateCloseCommitTimeUsesCustomProductionBeaconLead(t *testing.T) { + t.Parallel() + + roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) + const customLead uint32 = 12 + policy := AssurancePolicy{PublicWitnessesPerPhase: 1} + definition := CeremonyDefinition{ + Schema: DefinitionSchema, + Mode: ModeProduction, + AssurancePolicy: &policy, + BeaconPolicy: BeaconPolicy{ + MinimumWitnessLeadSeconds: customLead, + }, + } + requiredLead := time.Duration( + customLead+ProductionWitnessObservationWindowSeconds, + )*time.Second + closePublicationSafetyMargin + + closedAt := roundTime.Add(-requiredLead - time.Second) + if err := validateCloseCommitTime( + closedAt, + roundTime.Add(-requiredLead), + roundTime, + definition, + ); err != nil { + t.Fatalf("custom production beacon lead rejected: %v", err) + } + if err := validateCloseCommitTime( + closedAt, + roundTime.Add(-requiredLead+time.Second), + roundTime, + definition, + ); err == nil || !strings.Contains(err.Error(), "below required") { + t.Fatalf("shortened custom production wait error = %v, want lead rejection", err) + } +} diff --git a/internal/mpcceremony/deceptive_names_test.go b/internal/mpcceremony/deceptive_names_test.go index f9263a0d..e08319a5 100644 --- a/internal/mpcceremony/deceptive_names_test.go +++ b/internal/mpcceremony/deceptive_names_test.go @@ -129,6 +129,8 @@ func TestArtifactNameRejectsDeceptiveRunes(t *testing.T) { "phase1/" + rlo + "gnp.nib", "phase1/chain" + zwsp + "-0001.json", "phase1/" + rli + "chain.json", + "../outside.bin", + "phase1/../../outside.bin", } { if err := validateArtifactName(name); err == nil { t.Fatalf("artifact name %q was accepted", name) @@ -144,3 +146,24 @@ func TestArtifactNameRejectsDeceptiveRunes(t *testing.T) { } } } + +func TestCandidateMetadataRequiresCanonicalArtifactNames(t *testing.T) { + base := CandidateMetadata{ + ConstraintSystem: ArtifactRef{Name: "ownership-destination.ccs"}, + ProvingKey: ArtifactRef{Name: NativeProvingKeyFile}, + VerifyingKey: ArtifactRef{Name: NativeVerifyingKeyFile}, + CardanoVerifyingKey: ArtifactRef{Name: CardanoVKBytesFile}, + CardanoVKHex: ArtifactRef{Name: CardanoVKHexFile}, + CardanoVKFormat: ArtifactRef{Name: CardanoVKFormatFile}, + VerificationReport: ArtifactRef{Name: VerificationReportFile}, + PublicEvidence: ArtifactRef{Name: PublicEvidenceFile}, + Phase2SealRecord: ArtifactRef{Name: Phase2SealFile}, + } + for _, name := range []string{"other.pk", "../outside.pk"} { + candidate := base + candidate.ProvingKey.Name = name + if err := validateCandidateArtifactNames(candidate); err == nil { + t.Fatalf("candidate proving-key name %q was accepted", name) + } + } +} diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index 8f748450..235c9f03 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -21,8 +21,10 @@ import ( ) const ( - ProductionDecisionSchema = "proof-tool-mpc-production-decision-v1" - ProductionDecisionDraftSchema = "proof-tool-mpc-production-decision-draft-v1" + ProductionDecisionSchemaV1 = "proof-tool-mpc-production-decision-v1" + ProductionDecisionSchema = "proof-tool-mpc-production-decision-v2" + ProductionDecisionDraftSchemaV1 = "proof-tool-mpc-production-decision-draft-v1" + ProductionDecisionDraftSchema = "proof-tool-mpc-production-decision-draft-v2" ProductionDecisionSignatureSchema = "proof-tool-mpc-production-decision-signature-v1" // MaxProductionReleaseArtifacts must admit the largest release tree the // earlier layers can produce, or a fully valid signed release strands at @@ -44,9 +46,10 @@ const ( type ProductionGateStatus string const ( - GatePASS ProductionGateStatus = "PASS" - GateFAIL ProductionGateStatus = "FAIL" - GatePENDING ProductionGateStatus = "PENDING" + GatePASS ProductionGateStatus = "PASS" + GateFAIL ProductionGateStatus = "FAIL" + GatePENDING ProductionGateStatus = "PENDING" + GateNotRequired ProductionGateStatus = "NOT_REQUIRED" ) type ProductionGate string @@ -332,6 +335,18 @@ func (g ProductionGateResult) Validate() error { if strings.TrimSpace(g.Rationale) == "" || g.Rationale != strings.TrimSpace(g.Rationale) { return fmt.Errorf("%s gate %q requires a non-empty trimmed rationale", g.Status, g.Gate) } + case GateNotRequired: + switch g.Gate { + case GateIndependentAudits, GateExternalAudit, GatePublicWitnessing, GateImmutableMirrors: + default: + return fmt.Errorf("gate %q is never optional", g.Gate) + } + if len(g.Evidence) != 0 { + return fmt.Errorf("NOT_REQUIRED gate %q must not contain evidence", g.Gate) + } + if strings.TrimSpace(g.Rationale) == "" || g.Rationale != strings.TrimSpace(g.Rationale) { + return fmt.Errorf("NOT_REQUIRED gate %q requires a non-empty trimmed rationale", g.Gate) + } default: return fmt.Errorf("unsupported production gate status %q", g.Status) } @@ -361,6 +376,7 @@ type ProductionDecision struct { Schema string `json:"schema"` DecisionID string `json:"decision_id"` CeremonyID string `json:"ceremony_id"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` Release SignedReleaseEvidence `json:"release"` SourceRelease SourceReleaseEvidence `json:"source_release"` OperationalEvidence SignedLocatedArtifact `json:"operational_evidence"` @@ -380,6 +396,7 @@ type ProductionDecision struct { type ProductionDecisionDraft struct { Schema string `json:"schema"` CeremonyID string `json:"ceremony_id"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` Release SignedReleaseEvidenceDraft `json:"release"` SourceRelease SourceReleaseEvidence `json:"source_release"` OperationalEvidence SignedLocatedArtifact `json:"operational_evidence"` @@ -399,19 +416,36 @@ func (d ProductionDecisionDraft) Validate() error { } func (d ProductionDecisionDraft) decision() (ProductionDecision, error) { - if d.Schema != ProductionDecisionDraftSchema { + if d.Schema != ProductionDecisionDraftSchema && d.Schema != ProductionDecisionDraftSchemaV1 { return ProductionDecision{}, fmt.Errorf( "production decision draft schema %q, want %q", d.Schema, ProductionDecisionDraftSchema, ) } + if d.Schema == ProductionDecisionDraftSchema && + (d.Audits == nil || d.ExternalAudits == nil || d.Gates == nil) { + return ProductionDecision{}, errors.New("production decision draft v2 requires explicit audits, external_audits, and gates arrays") + } + if d.Schema == ProductionDecisionDraftSchema { + for _, gate := range d.Gates { + if gate.Evidence == nil { + return ProductionDecision{}, fmt.Errorf("draft gate %q requires an explicit evidence array", gate.Gate) + } + } + } release, err := d.Release.release() if err != nil { return ProductionDecision{}, fmt.Errorf("draft release: %w", err) } + decisionSchema := ProductionDecisionSchema + if d.Schema == ProductionDecisionDraftSchemaV1 { + decisionSchema = ProductionDecisionSchemaV1 + } return NewProductionDecision(ProductionDecision{ + Schema: decisionSchema, CeremonyID: d.CeremonyID, + AssurancePolicy: cloneAssurancePolicy(d.AssurancePolicy), Release: release, SourceRelease: d.SourceRelease, OperationalEvidence: d.OperationalEvidence, @@ -455,7 +489,22 @@ func PrepareProductionDecision( } func NewProductionDecision(value ProductionDecision) (ProductionDecision, error) { - value.Schema = ProductionDecisionSchema + if value.Schema == "" { + value.Schema = ProductionDecisionSchema + } + if value.Schema == ProductionDecisionSchema { + if value.Audits == nil { + value.Audits = []ProductionAuditEvidence{} + } + if value.ExternalAudits == nil { + value.ExternalAudits = []ExternalAuditEvidence{} + } + for index := range value.Gates { + if value.Gates[index].Evidence == nil { + value.Gates[index].Evidence = []LocatedArtifactRef{} + } + } + } value.DecisionID = "" id, err := computeProductionDecisionID(value) if err != nil { @@ -466,8 +515,20 @@ func NewProductionDecision(value ProductionDecision) (ProductionDecision, error) } func (d ProductionDecision) Validate() error { - if d.Schema != ProductionDecisionSchema { - return fmt.Errorf("production decision schema %q, want %q", d.Schema, ProductionDecisionSchema) + switch d.Schema { + case ProductionDecisionSchema: + if d.AssurancePolicy == nil { + return errors.New("production decision v2 requires assurance_policy") + } + if d.Audits == nil || d.ExternalAudits == nil || d.Gates == nil { + return errors.New("production decision v2 requires explicit audits, external_audits, and gates arrays") + } + case ProductionDecisionSchemaV1: + if d.AssurancePolicy != nil { + return errors.New("production decision v1 must not contain assurance_policy") + } + default: + return fmt.Errorf("production decision schema %q is unsupported", d.Schema) } if err := validateHashID("decision_id", d.DecisionID); err != nil { return err @@ -493,7 +554,7 @@ func (d ProductionDecision) Validate() error { } // One is the floor, not the ceiling. Validate every supplied audit; // additional auditors remain supported and must use distinct identities. - if len(d.Audits) < 1 { + if d.Schema == ProductionDecisionSchemaV1 && len(d.Audits) < 1 { return fmt.Errorf("production decision requires at least one audit, got %d", len(d.Audits)) } auditKeyIDs := make(map[string]struct{}, len(d.Audits)) @@ -509,7 +570,7 @@ func (d ProductionDecision) Validate() error { } auditKeyIDs[audit.AuditorKeyID] = struct{}{} } - if len(d.ExternalAudits) < 1 { + if d.Schema == ProductionDecisionSchemaV1 && len(d.ExternalAudits) < 1 { return fmt.Errorf("production decision requires at least one external audit, got %d", len(d.ExternalAudits)) } externalFingerprints := make(map[string]struct{}, len(d.ExternalAudits)) @@ -540,7 +601,7 @@ func (d ProductionDecision) Validate() error { if len(d.Gates) != len(requiredProductionGates) { return fmt.Errorf("production decision has %d gates, want exactly %d", len(d.Gates), len(requiredProductionGates)) } - allPass := true + allSatisfied := true for index, expectedGate := range requiredProductionGates { gate := d.Gates[index] if gate.Gate != expectedGate { @@ -549,15 +610,26 @@ func (d ProductionDecision) Validate() error { if err := gate.Validate(); err != nil { return err } - allPass = allPass && gate.Status == GatePASS + if d.Schema == ProductionDecisionSchema && gate.Evidence == nil { + return fmt.Errorf("gate %q requires an explicit evidence array; use [] when there is no evidence", gate.Gate) + } + if d.Schema == ProductionDecisionSchemaV1 && gate.Status == GateNotRequired { + return fmt.Errorf("legacy production decision gate %q cannot be NOT_REQUIRED", gate.Gate) + } + allSatisfied = allSatisfied && (gate.Status == GatePASS || gate.Status == GateNotRequired) + } + if d.Schema == ProductionDecisionSchema { + if err := validateAssuranceDecisionGates(*d.AssurancePolicy, d); err != nil { + return err + } } switch d.Decision { case DecisionGO: - if !allPass { + if !allSatisfied { return errors.New("GO decision requires every production gate to be PASS") } case DecisionNOGO: - if allPass { + if allSatisfied { return errors.New("NO-GO decision must enumerate at least one FAIL or PENDING gate") } default: @@ -571,10 +643,13 @@ func (d ProductionDecision) Validate() error { func computeProductionDecisionID(value ProductionDecision) (string, error) { value.DecisionID = "" - if value.Schema != ProductionDecisionSchema { - return "", fmt.Errorf("production decision schema %q, want %q", value.Schema, ProductionDecisionSchema) + domain := "proof-tool/mpc-ceremony/production-decision/v2" + if value.Schema == ProductionDecisionSchemaV1 { + domain = "proof-tool/mpc-ceremony/production-decision/v1" + } else if value.Schema != ProductionDecisionSchema { + return "", fmt.Errorf("production decision schema %q is unsupported", value.Schema) } - return canonicalHash("proof-tool/mpc-ceremony/production-decision/v1", value) + return canonicalHash(domain, value) } type DecisionSignerRole string @@ -784,6 +859,16 @@ func validateProductionDecisionBinding(definition CeremonyDefinition, decision P if decision.CeremonyID != definition.CeremonyID { return errors.New("production decision ceremony_id does not match the signed definition") } + if definition.Schema == DefinitionSchema { + if decision.Schema != ProductionDecisionSchema || decision.AssurancePolicy == nil || *decision.AssurancePolicy != *definition.AssurancePolicy { + return errors.New("production decision assurance_policy does not exactly match signed definition") + } + if err := validateAssuranceDecisionGates(*definition.AssurancePolicy, decision); err != nil { + return err + } + } else if decision.Schema != ProductionDecisionSchemaV1 || decision.AssurancePolicy != nil { + return errors.New("legacy definition requires legacy production decision semantics") + } if decision.SourceRelease.SourceCommit != definition.Software.SourceCommit { return errors.New("production decision source release does not match ceremony build provenance") } @@ -816,6 +901,50 @@ func validateProductionDecisionBinding(definition CeremonyDefinition, decision P return nil } +func expectedFinalTranscriptSchema(definition CeremonyDefinition) string { + if definition.Schema == DefinitionSchema { + return FinalTranscriptSchema + } + return FinalTranscriptSchemaV1 +} + +func validateAssuranceDecisionGates(policy AssurancePolicy, decision ProductionDecision) error { + if len(decision.Audits) < int(policy.PassingCeremonyAudits) { + return fmt.Errorf("passing ceremony audit count %d does not satisfy signed minimum %d", len(decision.Audits), policy.PassingCeremonyAudits) + } + if policy.PassingCeremonyAudits == 0 && len(decision.Audits) != 0 { + return errors.New("ceremony audits are forbidden when disabled by signed policy") + } + if len(decision.ExternalAudits) < int(policy.ExternalSecurityAuditSignoffs) { + return fmt.Errorf("external audit signoff count %d does not satisfy signed minimum %d", len(decision.ExternalAudits), policy.ExternalSecurityAuditSignoffs) + } + if policy.ExternalSecurityAuditSignoffs == 0 && len(decision.ExternalAudits) != 0 { + return errors.New("external audits are forbidden when disabled by signed policy") + } + statuses := make(map[ProductionGate]ProductionGateStatus, len(decision.Gates)) + for _, gate := range decision.Gates { + statuses[gate.Gate] = gate.Status + } + for _, requirement := range []struct { + gate ProductionGate + enabled bool + }{ + {GateIndependentAudits, policy.PassingCeremonyAudits > 0}, + {GateExternalAudit, policy.ExternalSecurityAuditSignoffs > 0}, + {GatePublicWitnessing, policy.PublicWitnessesPerPhase > 0}, + {GateImmutableMirrors, policy.MirrorsPerAcceptedHead > 0}, + } { + status := statuses[requirement.gate] + if requirement.enabled && status == GateNotRequired { + return fmt.Errorf("gate %q cannot be NOT_REQUIRED because it is enabled by signed policy", requirement.gate) + } + if !requirement.enabled && status != GateNotRequired { + return fmt.Errorf("gate %q must be NOT_REQUIRED because it is disabled by signed policy", requirement.gate) + } + } + return nil +} + func verifyDecisionRelease(definition CeremonyDefinition, decision ProductionDecision, root string) error { if err := verifyDecisionReleaseTree(decision.Release, root); err != nil { return err @@ -970,6 +1099,8 @@ func verifyDecisionRelease(definition CeremonyDefinition, decision ProductionDec ), } if transcript.CeremonyID != definition.CeremonyID || + transcript.Schema != expectedFinalTranscriptSchema(definition) || + !reflect.DeepEqual(transcript.AssurancePolicy, definition.AssurancePolicy) || transcript.Definition != candidate.Definition || !equalCircuitBinding(transcript.Circuit, candidate.Circuit) || !reflect.DeepEqual(transcript.Phase1, candidate.Phase1) || @@ -989,6 +1120,16 @@ func verifyDecisionRelease(definition CeremonyDefinition, decision ProductionDec if err != nil { return fmt.Errorf("final transcript release time: %w", err) } + candidateTime, err := time.Parse(time.RFC3339Nano, candidate.FinalizedAt) + if err != nil { + return fmt.Errorf("candidate finalized_at: %w", err) + } + // Full audit chronology is verified when the signed audit records are read. + // This check is independent so an all-zero audit policy cannot erase the + // candidate-to-release ordering requirement. + if err := validateReleaseChronology(transcriptTime, candidateTime, time.Time{}); err != nil { + return fmt.Errorf("final transcript: %w", err) + } coordinatorKey, err = identityPublicKey(definition.Coordinator) if err != nil { return err diff --git a/internal/mpcceremony/decision_test.go b/internal/mpcceremony/decision_test.go index 18f1e6a2..9b7b6701 100644 --- a/internal/mpcceremony/decision_test.go +++ b/internal/mpcceremony/decision_test.go @@ -501,7 +501,7 @@ func TestProductionDecisionNOGOMayBeSignedByOneAuthorizedRole(t *testing.T) { func newProductionDecisionFixture(t *testing.T, outcome ProductionDecisionOutcome) productionDecisionFixture { t.Helper() - operationalFixture := newOperationalBundleFixture(t) + operationalFixture := newLegacyOperationalBundleFixture(t) root := t.TempDir() copyRegularTree(t, operationalFixture.root, filepath.Join(root, "release")) definition := operationalFixture.definition @@ -784,6 +784,7 @@ func newProductionDecisionFixture(t *testing.T, outcome ProductionDecisionOutcom gates[len(gates)-1].Rationale = "The live twenty-party production ceremony has not occurred." } decision, err := NewProductionDecision(ProductionDecision{ + Schema: ProductionDecisionSchemaV1, CeremonyID: definition.CeremonyID, Release: release, SourceRelease: SourceReleaseEvidence{ @@ -1060,9 +1061,14 @@ func signedExternalAuditFixture( } func productionDecisionDraft(decision ProductionDecision) ProductionDecisionDraft { + schema := ProductionDecisionDraftSchema + if decision.Schema == ProductionDecisionSchemaV1 { + schema = ProductionDecisionDraftSchemaV1 + } return ProductionDecisionDraft{ - Schema: ProductionDecisionDraftSchema, - CeremonyID: decision.CeremonyID, + Schema: schema, + CeremonyID: decision.CeremonyID, + AssurancePolicy: cloneAssurancePolicy(decision.AssurancePolicy), Release: SignedReleaseEvidenceDraft{ CandidateID: decision.Release.CandidateID, Manifest: decision.Release.Manifest, diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index 0365d82c..f4b40d35 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -5,7 +5,14 @@ import ( "fmt" ) -const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 +// RecommendedProductionBeaconLeadSeconds is the conservative production +// default used by ceremony tooling. The exact value is ceremony policy: it is +// signed into the definition and may be changed before initialization. +const RecommendedProductionBeaconLeadSeconds uint32 = 24 * 60 * 60 + +// ProductionMinimumWitnessLeadSeconds is retained for source compatibility. +// It is a recommended default, not a validator-enforced floor. +const ProductionMinimumWitnessLeadSeconds = RecommendedProductionBeaconLeadSeconds // ProductionWitnessObservationWindowSeconds is the observation time a // production close must reserve for public witnesses on top of the signed @@ -23,21 +30,75 @@ const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 const ProductionWitnessObservationWindowSeconds uint32 = 60 * 60 type CeremonyDefinition struct { - Schema string `json:"schema"` - CeremonyID string `json:"ceremony_id"` - Mode string `json:"mode"` - CreatedAt string `json:"created_at"` - SessionNonceHex string `json:"session_nonce_hex"` - Circuit CircuitBinding `json:"circuit"` - Software SoftwareBinding `json:"software"` - Coordinator Identity `json:"coordinator"` - ReleaseSigner Identity `json:"release_signer"` - Auditors []Identity `json:"auditors"` - Roster []Participant `json:"roster"` - Phase1Policy PhasePolicy `json:"phase1_policy"` - Phase2Policy PhasePolicy `json:"phase2_policy"` - BeaconPolicy BeaconPolicy `json:"beacon_policy"` - Phase1Genesis ArtifactRef `json:"phase1_genesis"` + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + CreatedAt string `json:"created_at"` + SessionNonceHex string `json:"session_nonce_hex"` + Circuit CircuitBinding `json:"circuit"` + Software SoftwareBinding `json:"software"` + Coordinator Identity `json:"coordinator"` + ReleaseSigner Identity `json:"release_signer"` + Auditors []Identity `json:"auditors"` + Roster []Participant `json:"roster"` + Phase1Policy PhasePolicy `json:"phase1_policy"` + Phase2Policy PhasePolicy `json:"phase2_policy"` + BeaconPolicy BeaconPolicy `json:"beacon_policy"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` + Phase1Genesis ArtifactRef `json:"phase1_genesis"` +} + +// AssurancePolicy is the signed, ceremony-wide authority for optional +// operational observers and reviews. A zero explicitly disables that control; +// omission is never interpreted as zero in definition v3. +type AssurancePolicy struct { + PublicWitnessesPerPhase uint8 `json:"public_witnesses_per_phase"` + MirrorsPerAcceptedHead uint8 `json:"mirrors_per_accepted_head"` + PassingCeremonyAudits uint8 `json:"passing_ceremony_audits"` + ExternalSecurityAuditSignoffs uint8 `json:"external_security_audit_signoffs"` +} + +func (p AssurancePolicy) Validate(mode string, auditorCount int) error { + if p.PublicWitnessesPerPhase > MaxAuditors { + return fmt.Errorf("public_witnesses_per_phase exceeds maximum %d", MaxAuditors) + } + if p.MirrorsPerAcceptedHead > MaxAuditors { + return fmt.Errorf("mirrors_per_accepted_head exceeds maximum %d", MaxAuditors) + } + if int(p.PassingCeremonyAudits) > auditorCount { + return fmt.Errorf("passing_ceremony_audits %d exceeds auditor roster size %d", p.PassingCeremonyAudits, auditorCount) + } + if p.PassingCeremonyAudits == 0 && auditorCount != 0 { + return errors.New("auditor roster must be empty when passing_ceremony_audits is zero") + } + if p.ExternalSecurityAuditSignoffs > MaxAuditors { + return fmt.Errorf("external_security_audit_signoffs exceeds maximum %d", MaxAuditors) + } + if mode == ModeRehearsal && p.ExternalSecurityAuditSignoffs != 0 { + return errors.New("rehearsal ceremonies must set external_security_audit_signoffs to zero") + } + return nil +} + +func defaultAssurancePolicy(mode string) AssurancePolicy { + external := uint8(1) + if mode == ModeRehearsal { + external = 0 + } + return AssurancePolicy{ + PublicWitnessesPerPhase: 1, + MirrorsPerAcceptedHead: 1, + PassingCeremonyAudits: 1, + ExternalSecurityAuditSignoffs: external, + } +} + +func cloneAssurancePolicy(policy *AssurancePolicy) *AssurancePolicy { + if policy == nil { + return nil + } + value := *policy + return &value } type DefinitionOptions struct { @@ -53,6 +114,7 @@ type DefinitionOptions struct { Phase1Policy PhasePolicy Phase2Policy PhasePolicy BeaconPolicy BeaconPolicy + AssurancePolicy *AssurancePolicy Phase1Genesis ArtifactRef } @@ -61,6 +123,11 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error if len(software.Binaries) == 0 { software.Binaries = []SoftwareBinary{software.primaryBinary()} } + assurance := cloneAssurancePolicy(options.AssurancePolicy) + if assurance == nil { + value := defaultAssurancePolicy(options.Mode) + assurance = &value + } definition := CeremonyDefinition{ Schema: DefinitionSchema, Mode: options.Mode, @@ -70,11 +137,12 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error Software: software, Coordinator: options.Coordinator, ReleaseSigner: options.ReleaseSigner, - Auditors: append([]Identity(nil), options.Auditors...), + Auditors: append([]Identity{}, options.Auditors...), Roster: append([]Participant(nil), options.Roster...), Phase1Policy: clonePhasePolicy(options.Phase1Policy), Phase2Policy: clonePhasePolicy(options.Phase2Policy), BeaconPolicy: options.BeaconPolicy, + AssurancePolicy: assurance, Phase1Genesis: options.Phase1Genesis, } id, err := ComputeCeremonyID(definition) @@ -93,6 +161,15 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error // compilation from metadata construction. func FinalizeCeremonyDefinition(definition CeremonyDefinition) (CeremonyDefinition, error) { definition.Schema = DefinitionSchema + if definition.Auditors == nil { + definition.Auditors = []Identity{} + } + if definition.AssurancePolicy == nil { + value := defaultAssurancePolicy(definition.Mode) + definition.AssurancePolicy = &value + } else { + definition.AssurancePolicy = cloneAssurancePolicy(definition.AssurancePolicy) + } if len(definition.Software.Binaries) == 0 { definition.Software.Binaries = []SoftwareBinary{definition.Software.primaryBinary()} } @@ -113,9 +190,12 @@ func ComputeCeremonyID(definition CeremonyDefinition) (string, error) { if err := definition.validate(false); err != nil { return "", err } - domain := "proof-tool/mpc-ceremony/root/v2" - if definition.Schema == DefinitionSchemaV1 { + domain := "proof-tool/mpc-ceremony/root/v3" + switch definition.Schema { + case DefinitionSchemaV1: domain = "proof-tool/mpc-ceremony/root/v1" + case DefinitionSchemaV2: + domain = "proof-tool/mpc-ceremony/root/v2" } return canonicalHash(domain, definition) } @@ -137,14 +217,18 @@ func (d CeremonyDefinition) Validate() error { func (d CeremonyDefinition) validate(requireID bool) error { switch d.Schema { case DefinitionSchema: + case DefinitionSchemaV2: + if d.AssurancePolicy != nil { + return errors.New("definition v2 must not contain v3-only assurance_policy") + } case DefinitionSchemaV1: - if len(d.Software.Binaries) != 0 || d.Software.GoARM64 != "" { - return errors.New("definition v1 must not contain v2-only fields") + if len(d.Software.Binaries) != 0 || d.Software.GoARM64 != "" || d.AssurancePolicy != nil { + return errors.New("definition v1 must not contain newer-schema fields") } default: return fmt.Errorf( - "definition schema %q, want %q or %q", - d.Schema, DefinitionSchemaV1, DefinitionSchema, + "definition schema %q, want %q, %q or %q", + d.Schema, DefinitionSchemaV1, DefinitionSchemaV2, DefinitionSchema, ) } if requireID { @@ -207,8 +291,8 @@ func (d CeremonyDefinition) validate(requireID bool) error { if err := d.Software.Validate(); err != nil { return fmt.Errorf("software: %w", err) } - if d.Schema == DefinitionSchema && len(d.Software.Binaries) == 0 { - return errors.New("definition v2 requires at least one allowed software binary") + if (d.Schema == DefinitionSchema || d.Schema == DefinitionSchemaV2) && len(d.Software.Binaries) == 0 { + return errors.New("definition v2 or v3 requires at least one allowed software binary") } if d.Mode == ModeProduction { for index, binary := range d.Software.AllowedBinaries() { @@ -236,8 +320,8 @@ func (d CeremonyDefinition) validate(requireID bool) error { if d.ReleaseSigner.ID == d.Coordinator.ID || d.ReleaseSigner.KeyID == d.Coordinator.KeyID { return errors.New("release signer must be distinct from coordinator") } - if len(d.Auditors) < 1 { - return errors.New("at least one independent auditor is required") + if d.Schema == DefinitionSchema && d.Auditors == nil { + return errors.New("definition v3 requires an explicit auditors array; use [] when audits are disabled") } if len(d.Auditors) > MaxAuditors { return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) @@ -274,6 +358,16 @@ func (d CeremonyDefinition) validate(requireID bool) error { keyIDs[auditor.KeyID] = "auditor" publicKeyFingerprints[auditor.PublicKeyFingerprint] = "auditor" } + if d.Schema == DefinitionSchema { + if d.AssurancePolicy == nil { + return errors.New("definition v3 requires assurance_policy; omission does not disable controls") + } + if err := d.AssurancePolicy.Validate(d.Mode, len(d.Auditors)); err != nil { + return fmt.Errorf("assurance_policy: %w", err) + } + } else if len(d.Auditors) < 1 { + return errors.New("legacy definitions require at least one independent auditor") + } if len(d.Roster) == 0 || len(d.Roster) > MaxParticipants { return fmt.Errorf("roster must contain between 1 and %d participants", MaxParticipants) } @@ -308,13 +402,6 @@ func (d CeremonyDefinition) validate(requireID bool) error { return fmt.Errorf("phase2_policy: %w", err) } if d.Mode == ModeProduction { - if d.BeaconPolicy.MinimumWitnessLeadSeconds < ProductionMinimumWitnessLeadSeconds { - return fmt.Errorf( - "production beacon minimum_witness_lead_seconds %d is below required %d", - d.BeaconPolicy.MinimumWitnessLeadSeconds, - ProductionMinimumWitnessLeadSeconds, - ) - } if len(d.Roster) < 2 { return errors.New("production ceremony requires at least two distinct roster participants") } diff --git a/internal/mpcceremony/definition_test.go b/internal/mpcceremony/definition_test.go index 5047b1fe..f1f1374e 100644 --- a/internal/mpcceremony/definition_test.go +++ b/internal/mpcceremony/definition_test.go @@ -1,6 +1,8 @@ package mpcceremony import ( + "os" + "path/filepath" "strings" "testing" ) @@ -9,6 +11,7 @@ func TestDefinitionV1RemainsAValidSingletonBinaryPolicy(t *testing.T) { definition := adversarialDefinition(t) definition.Schema = DefinitionSchemaV1 definition.Software.Binaries = nil + definition.AssurancePolicy = nil definition.CeremonyID = "" id, err := ComputeCeremonyID(definition) if err != nil { @@ -23,6 +26,27 @@ func TestDefinitionV1RemainsAValidSingletonBinaryPolicy(t *testing.T) { } } +func TestDefinitionV2RemainsValidWithLegacyAssuranceMinimums(t *testing.T) { + definition := adversarialDefinition(t) + definition.Schema = DefinitionSchemaV2 + definition.AssurancePolicy = nil + definition.CeremonyID = "" + id, err := ComputeCeremonyID(definition) + if err != nil { + t.Fatal(err) + } + definition.CeremonyID = id + if err := definition.Validate(); err != nil { + t.Fatalf("legacy v2 definition rejected: %v", err) + } + + definition.Auditors = nil + definition.CeremonyID = "" + if _, err := ComputeCeremonyID(definition); err == nil { + t.Fatal("legacy v2 definition unexpectedly allowed zero ceremony auditors") + } +} + func TestProductionDefinitionRequiresCanonicalDestinationCircuit(t *testing.T) { tests := []struct { name string @@ -93,11 +117,34 @@ func TestProductionDefinitionRequiresMultipleParticipantsInBothPhases(t *testing } } +func TestProductionDefinitionAcceptsSignedCustomBeaconLead(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + definition.BeaconPolicy.MinimumWitnessLeadSeconds = 12 + finalized, err := FinalizeCeremonyDefinition(definition) + if err != nil { + t.Fatalf("production definition with signed custom beacon lead rejected: %v", err) + } + if got := finalized.BeaconPolicy.MinimumWitnessLeadSeconds; got != 12 { + t.Fatalf("production beacon lead = %d, want 12", got) + } + + definition = finalized + definition.CeremonyID = "" + definition.BeaconPolicy.MinimumWitnessLeadSeconds = 0 + if _, err := FinalizeCeremonyDefinition(definition); err == nil { + t.Fatal("production definition with zero beacon lead unexpectedly accepted") + } +} + func TestRehearsalDefinitionMayUseOneParticipant(t *testing.T) { valid := adversarialDefinition(t) rehearsal := valid rehearsal.CeremonyID = "" rehearsal.Mode = ModeRehearsal + assurance := *rehearsal.AssurancePolicy + assurance.ExternalSecurityAuditSignoffs = 0 + rehearsal.AssurancePolicy = &assurance rehearsal.Roster = append([]Participant(nil), valid.Roster[:1]...) rehearsal.Phase1Policy = PhasePolicy{ Participants: []string{valid.Roster[0].Identity.ID}, @@ -109,6 +156,126 @@ func TestRehearsalDefinitionMayUseOneParticipant(t *testing.T) { } } +func TestDefinitionV3RequiresExplicitSignedAssurancePolicy(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + definition.AssurancePolicy = nil + if _, err := ComputeCeremonyID(definition); err == nil || !strings.Contains(err.Error(), "requires assurance_policy") { + t.Fatalf("missing assurance policy error = %v", err) + } +} + +func TestDefinitionV3AllowsEveryOperationalControlToBeDisabled(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + definition.Auditors = nil + definition.AssurancePolicy = &AssurancePolicy{} + if _, err := FinalizeCeremonyDefinition(definition); err != nil { + t.Fatalf("all-optional production definition rejected: %v", err) + } +} + +func TestDefinitionV3CeremonyIDBindsAssurancePolicy(t *testing.T) { + definition := adversarialDefinition(t) + original := definition.CeremonyID + definition.CeremonyID = "" + policy := *definition.AssurancePolicy + policy.MirrorsPerAcceptedHead = 0 + definition.AssurancePolicy = &policy + changed, err := FinalizeCeremonyDefinition(definition) + if err != nil { + t.Fatal(err) + } + if changed.CeremonyID == original { + t.Fatal("changing the signed assurance policy did not change ceremony_id") + } +} + +func TestDefinitionV3AssurancePolicyIsFailClosed(t *testing.T) { + tests := []struct { + name string + mutate func(*CeremonyDefinition) + }{ + { + name: "audit minimum exceeds roster", + mutate: func(definition *CeremonyDefinition) { + definition.AssurancePolicy.PassingCeremonyAudits = uint8(len(definition.Auditors) + 1) + }, + }, + { + name: "external audits enabled in rehearsal", + mutate: func(definition *CeremonyDefinition) { + definition.Mode = ModeRehearsal + definition.AssurancePolicy.ExternalSecurityAuditSignoffs = 1 + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + test.mutate(&definition) + if _, err := FinalizeCeremonyDefinition(definition); err == nil { + t.Fatal("invalid assurance policy unexpectedly accepted") + } + }) + } +} + +func TestInitPolicyDoesNotTreatOmissionAsZero(t *testing.T) { + policy := InitPolicy{ + Phase1Policy: PhasePolicy{Participants: []string{"participant-01"}, Minimum: 1}, + Phase2Policy: PhasePolicy{Participants: []string{"participant-01"}, Minimum: 1}, + BeaconPolicy: adversarialDefinition(t).BeaconPolicy, + } + if err := policy.Validate(); err == nil || !strings.Contains(err.Error(), "assurance_policy is required") { + t.Fatalf("omitted assurance policy error = %v", err) + } + policy.AssurancePolicy = &AssurancePolicy{} + if err := policy.Validate(); err != nil { + t.Fatalf("explicit zero assurance policy rejected structurally: %v", err) + } +} + +func TestLoadLegacyInitPolicyRestoresOldNonzeroDefaults(t *testing.T) { + definition := adversarialDefinition(t) + legacy := legacyInitPolicy{definition.Phase1Policy, definition.Phase2Policy, definition.BeaconPolicy} + raw, err := MarshalCanonical(legacy) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "policy.json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } + loaded, err := LoadInitPolicy(path) + if err != nil { + t.Fatal(err) + } + policy, err := loaded.ResolvedAssurancePolicy(ModeProduction) + if err != nil { + t.Fatal(err) + } + if policy.PublicWitnessesPerPhase == 0 || policy.MirrorsPerAcceptedHead == 0 || + policy.PassingCeremonyAudits == 0 || policy.ExternalSecurityAuditSignoffs == 0 { + t.Fatalf("legacy policy weakened to zero: %#v", policy) + } +} + +func TestCurrentDefinitionRequiresExplicitEmptyAuditorArray(t *testing.T) { + definition := adversarialDefinition(t) + definition.AssurancePolicy.PassingCeremonyAudits = 0 + definition.Auditors = nil + definition.CeremonyID = "" + if _, err := ComputeCeremonyID(definition); err == nil || !strings.Contains(err.Error(), "explicit auditors array") { + t.Fatalf("nil auditors error = %v", err) + } + definition.Auditors = []Identity{} + if _, err := ComputeCeremonyID(definition); err != nil { + t.Fatalf("explicit empty auditors rejected: %v", err) + } +} + func TestDefinitionRequiresUniquePublicKeysAcrossAllRoles(t *testing.T) { reusePublicKey := func(destination *Identity, source Identity) { destination.Ed25519PublicKeyHex = source.Ed25519PublicKeyHex diff --git a/internal/mpcceremony/direct_acceptance_boundary_test.go b/internal/mpcceremony/direct_acceptance_boundary_test.go index cfcf817c..cff2654e 100644 --- a/internal/mpcceremony/direct_acceptance_boundary_test.go +++ b/internal/mpcceremony/direct_acceptance_boundary_test.go @@ -160,17 +160,20 @@ func newDirectAcceptanceFixture(t *testing.T) directAcceptanceFixture { } repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) root := t.TempDir() - helperPath := filepath.Join(root, "mpc-workflow-helper") - build := exec.Command( - "go", - "build", - "-o", - helperPath, - "./internal/mpcceremony/testdata/workflowhelper", - ) - build.Dir = repoRoot - if output, err := build.CombinedOutput(); err != nil { - t.Fatalf("build ordinary workflow helper: %v\n%s", err, output) + helperPath := os.Getenv("MPC_WORKFLOW_HELPER") + if helperPath == "" { + helperPath = filepath.Join(root, "mpc-workflow-helper") + build := exec.Command( + "go", + "build", + "-o", + helperPath, + "./internal/mpcceremony/testdata/workflowhelper", + ) + build.Dir = repoRoot + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build ordinary workflow helper: %v\n%s", err, output) + } } workflowRoot := filepath.Join(root, "workflow") run := exec.Command(helperPath, workflowRoot) @@ -220,6 +223,61 @@ func newDirectAcceptanceFixture(t *testing.T) directAcceptanceFixture { } } +func TestVerifyAcceptedPhase1ChainCheckpointBoundary(t *testing.T) { + fixture := newDirectAcceptanceFixture(t) + // TrustedCeremony deliberately does not retain source paths. Use the + // fixture's canonical layout for this public read-only entry point. + if _, _, err := verifyAcceptedPhase1ChainForTest(fixture.trusted, fixture.circuit, fixture.phase1Chain1); err != nil { + t.Fatalf("verify authentic accepted phase1 chain: %v", err) + } + + tests := []struct { + name string + path string + }{ + {name: "candidate mathematics", path: filepath.Join(fixture.ceremonyRoot, "phase1", "contributions", "0001", "contribution.bin")}, + {name: "cleanup signature", path: filepath.Join(fixture.ceremonyRoot, "phase1", "contributions", "0001", "erasure.sig")}, + {name: "accepted chain and head", path: fixture.phase1Chain1.ChainPath}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + original, err := os.ReadFile(test.path) + if err != nil { + t.Fatal(err) + } + changed := append([]byte(nil), original...) + changed[len(changed)/2] ^= 0x01 + if err := os.WriteFile(test.path, changed, 0o600); err != nil { + t.Fatal(err) + } + if _, _, err := verifyAcceptedPhase1ChainForTest(fixture.trusted, fixture.circuit, fixture.phase1Chain1); err == nil { + t.Fatal("changed accepted evidence passed full checkpoint replay") + } + if err := os.WriteFile(test.path, original, 0o600); err != nil { + t.Fatal(err) + } + }) + } +} + +// verifyAcceptedPhase1ChainForTest exercises the replay portion of the public +// boundary without pretending that the temporary Go test executable is a +// released ceremony binary. Production code has no equivalent bypass helper. +func verifyAcceptedPhase1ChainForTest(trusted *TrustedCeremony, circuit *CompiledCircuit, paths PhaseTranscriptPaths) (Chain, SignedArtifactRefs, error) { + if err := validateWorkflowCircuit(trusted, circuit); err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + chain, err := loadVerifiedPhase1Files(trusted, circuit, paths) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + _, refs, err := LoadSignedChainExact(trusted, paths) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + return chain, refs, nil +} + func forgeCommonsAndRebindEmptyPhase2Chain( t *testing.T, fixture directAcceptanceFixture, diff --git a/internal/mpcceremony/finalize.go b/internal/mpcceremony/finalize.go index 7d9f9017..75e9aefb 100644 --- a/internal/mpcceremony/finalize.go +++ b/internal/mpcceremony/finalize.go @@ -410,6 +410,9 @@ func (c CandidateMetadata) Validate() error { return fmt.Errorf("%s: %w", label, err) } } + if err := validateCandidateArtifactNames(c); err != nil { + return err + } if err := validateID("coordinator_id", c.CoordinatorID); err != nil { return err } @@ -419,6 +422,25 @@ func (c CandidateMetadata) Validate() error { return validateTimestamp("finalized_at", c.FinalizedAt) } +func validateCandidateArtifactNames(c CandidateMetadata) error { + for label, value := range map[string]struct{ actual, expected string }{ + "constraint_system": {c.ConstraintSystem.Name, prover.DestinationConstraintSystemFile}, + "proving_key": {c.ProvingKey.Name, NativeProvingKeyFile}, + "verifying_key": {c.VerifyingKey.Name, NativeVerifyingKeyFile}, + "cardano_verifying_key": {c.CardanoVerifyingKey.Name, CardanoVKBytesFile}, + "cardano_vk_hex": {c.CardanoVKHex.Name, CardanoVKHexFile}, + "cardano_vk_format": {c.CardanoVKFormat.Name, CardanoVKFormatFile}, + "verification_report": {c.VerificationReport.Name, VerificationReportFile}, + "public_evidence": {c.PublicEvidence.Name, PublicEvidenceFile}, + "phase2_seal_record": {c.Phase2SealRecord.Name, Phase2SealFile}, + } { + if value.actual != value.expected { + return fmt.Errorf("%s artifact name %q, want %q", label, value.actual, value.expected) + } + } + return nil +} + func computeCandidateID(candidate CandidateMetadata) (string, error) { candidate.CandidateID = "" if candidate.Schema != CandidateMetadataSchema { diff --git a/internal/mpcceremony/lifecycle_adversarial_test.go b/internal/mpcceremony/lifecycle_adversarial_test.go index 399a33c0..cea2c343 100644 --- a/internal/mpcceremony/lifecycle_adversarial_test.go +++ b/internal/mpcceremony/lifecycle_adversarial_test.go @@ -146,6 +146,67 @@ func TestSignedLifecycleReleaseRejectsCrossArtifactTampering(t *testing.T) { } } +func TestSignedLifecycleWithAllOptionalAssuranceDisabled(t *testing.T) { + if testing.Short() { + t.Skip("skipping complete all-zero assurance lifecycle") + } + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve lifecycle test source") + } + repoRoot := filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..")) + binaryDir := t.TempDir() + workflowHelper := filepath.Join(binaryDir, "workflow-helper") + operationalHelper := filepath.Join(binaryDir, "operational-helper") + for output, pkg := range map[string]string{ + workflowHelper: "./internal/mpcceremony/testdata/workflowhelper", + operationalHelper: "./scripts/mpc-rehearsal-operational-evidence", + } { + command := exec.Command("go", "build", "-o", output, pkg) + command.Dir = repoRoot + if combined, err := command.CombinedOutput(); err != nil { + t.Fatalf("build %s: %v\n%s", pkg, err, combined) + } + } + + workflowRoot := filepath.Join(t.TempDir(), "workflow") + command := exec.Command(workflowHelper, workflowRoot, operationalHelper) + command.Dir = repoRoot + command.Env = append(os.Environ(), "PROOF_TOOL_TEST_ZERO_ASSURANCE=1") + if combined, err := command.CombinedOutput(); err != nil { + t.Fatalf("run all-zero signed lifecycle: %v\n%s", err, combined) + } + ceremonyRoot := filepath.Join(workflowRoot, "ceremony") + coordinatorPublicKeyPath := filepath.Join(workflowRoot, "identity-keys", "trusted-coordinator.ed25519.public.hex") + trusted, err := LoadSignedDefinition(TrustPaths{ + DefinitionPath: filepath.Join(ceremonyRoot, "ceremony.json"), DefinitionSignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"), + CoordinatorPublicKeyPath: coordinatorPublicKeyPath, + }) + if err != nil { + t.Fatal(err) + } + if trusted.Definition.AssurancePolicy == nil || *trusted.Definition.AssurancePolicy != (AssurancePolicy{}) || len(trusted.Definition.Auditors) != 0 { + t.Fatalf("unexpected disabled assurance definition: %#v", trusted.Definition.AssurancePolicy) + } + coordinatorPublicKey, err := os.ReadFile(coordinatorPublicKeyPath) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyRelease(VerifyReleaseOptions{ + DefinitionPath: filepath.Join(ceremonyRoot, "ceremony.json"), DefinitionSignaturePath: filepath.Join(ceremonyRoot, "ceremony.sig"), + CoordinatorPublicKeyHex: strings.TrimSpace(string(coordinatorPublicKey)), KeysDir: filepath.Join(workflowRoot, "release"), + TrustedPublicKeyHex: trusted.Definition.ReleaseSigner.Ed25519PublicKeyHex, ExpectedSignatureKeyID: trusted.Definition.ReleaseSigner.KeyID, + RequireProvingKey: true, + }); err != nil { + t.Fatalf("verify all-zero signed lifecycle release: %v", err) + } + for _, id := range []string{"auditor-01", "witness-01", "mirror-01"} { + if _, err := os.Lstat(filepath.Join(workflowRoot, "identity-keys", id+".ed25519.private.hex")); !os.IsNotExist(err) { + t.Fatalf("disabled role key %q exists or cannot be inspected: %v", id, err) + } + } +} + func copyRegularTree(t *testing.T, source, destination string) { t.Helper() err := filepath.WalkDir(source, func(path string, entry fs.DirEntry, walkErr error) error { diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index da406f78..97561b38 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -20,7 +20,8 @@ import ( const ( DefinitionSchemaV1 = "proof-tool-mpc-ceremony-definition-v1" - DefinitionSchema = "proof-tool-mpc-ceremony-definition-v2" + DefinitionSchemaV2 = "proof-tool-mpc-ceremony-definition-v2" + DefinitionSchema = "proof-tool-mpc-ceremony-definition-v3" DetachedSignatureSchema = "proof-tool-mpc-detached-signature-v1" ContributionAttestationSchema = "proof-tool-mpc-contribution-attestation-v2" ErasureAttestationSchema = "proof-tool-mpc-erasure-attestation-v2" @@ -30,7 +31,8 @@ const ( BeaconRecordSchema = "proof-tool-mpc-beacon-record-v1" SealRecordSchema = "proof-tool-mpc-seal-record-v1" AuditRecordSchema = "proof-tool-mpc-audit-record-v1" - FinalTranscriptSchema = "proof-tool-mpc-final-transcript-v1" + FinalTranscriptSchemaV1 = "proof-tool-mpc-final-transcript-v1" + FinalTranscriptSchema = "proof-tool-mpc-final-transcript-v2" KeyVersionDestinationV2 = "ownership-destination-v2" CircuitIDDestinationV2 = "root-ownership-destination-v2/bls12-381/groth16" @@ -707,6 +709,9 @@ func validateArtifactName(value string) error { return fmt.Errorf("artifact name %q %w", value, err) } for segment := range strings.SplitSeq(value, "/") { + if segment == ".." { + return fmt.Errorf("artifact name %q must not escape its artifact root", value) + } if segment != strings.TrimSpace(segment) { return fmt.Errorf("artifact name %q has untrimmed whitespace in a path segment", value) } diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index 659cf7d3..25e320dd 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -812,8 +812,11 @@ func VerifyPublicWitnessQuorum( receipts []SignedPublicWitness, minimum int, ) error { - if minimum < 1 { - return errors.New("public witness quorum minimum must be at least 1") + if minimum < 0 { + return errors.New("public witness quorum minimum must not be negative") + } + if minimum == 0 && len(receipts) != 0 { + return errors.New("public witness receipts are forbidden when the signed quorum is zero") } if len(receipts) < minimum { return fmt.Errorf("have %d public witness receipts, need %d", len(receipts), minimum) @@ -863,6 +866,9 @@ func ValidatePublicWitnessReceipt( closeBytes []byte, receipt PublicWitnessReceipt, ) error { + if definition.Schema == DefinitionSchema && definition.AssurancePolicy != nil && definition.AssurancePolicy.PublicWitnessesPerPhase == 0 { + return errors.New("public witnessing is disabled by the signed assurance policy") + } if err := validatePublicWitnessCloseBinding(definition, close); err != nil { return err } @@ -1037,6 +1043,14 @@ func verifyEnrollmentBinding(definition CeremonyDefinition, definitionBytes []by identity, role, index, ok := definitionRoleAt(definition, record.Identity.ID) switch record.Role { case EnrollmentPublicWitness, EnrollmentMirrorOperator: + if definition.Schema == DefinitionSchema && definition.AssurancePolicy != nil { + if record.Role == EnrollmentPublicWitness && definition.AssurancePolicy.PublicWitnessesPerPhase == 0 { + return errors.New("public-witness enrollment is disabled by the signed assurance policy") + } + if record.Role == EnrollmentMirrorOperator && definition.AssurancePolicy.MirrorsPerAcceptedHead == 0 { + return errors.New("mirror-operator enrollment is disabled by the signed assurance policy") + } + } if ok || identityOverlapsDefinition(definition, record.Identity) { return errors.New("external operational identity overlaps a ceremony actor") } diff --git a/internal/mpcceremony/operational_builder.go b/internal/mpcceremony/operational_builder.go index c8021164..a99fb047 100644 --- a/internal/mpcceremony/operational_builder.go +++ b/internal/mpcceremony/operational_builder.go @@ -218,6 +218,9 @@ func PrepareImmutableMirrorReceipt( if err := definition.Validate(); err != nil { return ImmutableMirrorReceipt{}, nil, err } + if definition.Schema == DefinitionSchema && definition.AssurancePolicy.MirrorsPerAcceptedHead == 0 { + return ImmutableMirrorReceipt{}, nil, errors.New("mirrors are disabled by the signed assurance policy") + } if err := chain.ValidateAgainstDefinition(definition); err != nil { return ImmutableMirrorReceipt{}, nil, err } diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index 9e7b7f8c..76025967 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -10,7 +10,8 @@ import ( ) const ( - OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v2" + OperationalEvidenceBundleSchemaV2 = "proof-tool-mpc-operational-evidence-bundle-v2" + OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v3" ) type SignedArtifactRefs struct { @@ -71,8 +72,8 @@ func (e AcceptedHeadOperationalEvidence) Validate() error { if err := e.AcceptedChainPrefix.Validate(); err != nil { return fmt.Errorf("accepted_chain_prefix: %w", err) } - if len(e.MirrorReceipts) < 1 || len(e.MirrorReceipts) > 8 { - return errors.New("accepted head requires between 1 and 8 immutable mirror receipts") + if len(e.MirrorReceipts) > MaxAuditors { + return fmt.Errorf("accepted head mirror receipts exceed maximum %d", MaxAuditors) } return validateSignedArtifactSet("mirror_receipts", e.MirrorReceipts) } @@ -109,9 +110,6 @@ func (p PhaseOperationalEvidence) Validate() error { return errors.New("accepted heads must be complete and ordered by one-based index") } } - if p.PublicWitnessQuorum < 1 { - return errors.New("public_witness_quorum must be at least 1") - } if len(p.PublicWitnessReceipts) < int(p.PublicWitnessQuorum) || len(p.PublicWitnessReceipts) > 32 { return fmt.Errorf( "public witness receipt count %d does not satisfy quorum %d or maximum 32", @@ -138,6 +136,7 @@ func (p PhaseOperationalEvidence) Validate() error { type OperationalEvidenceBundle struct { Schema string `json:"schema"` CeremonyID string `json:"ceremony_id"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` Enrollments []SignedArtifactRefs `json:"enrollments"` GovernanceRecords []SignedArtifactRefs `json:"governance_records"` Phase1 PhaseOperationalEvidence `json:"phase1"` @@ -148,13 +147,34 @@ type OperationalEvidenceBundle struct { } func (b OperationalEvidenceBundle) Validate() error { - if b.Schema != OperationalEvidenceBundleSchema { + switch b.Schema { + case OperationalEvidenceBundleSchema: + if b.AssurancePolicy == nil { + return errors.New("operational evidence v3 requires assurance_policy") + } + if b.Enrollments == nil || b.GovernanceRecords == nil || + b.Phase1.AcceptedHeads == nil || b.Phase1.PublicWitnessReceipts == nil || b.Phase1.RawBeaconResponses == nil || + b.Phase2.AcceptedHeads == nil || b.Phase2.PublicWitnessReceipts == nil || b.Phase2.RawBeaconResponses == nil { + return errors.New("operational evidence v3 requires explicit arrays; use [] for enabled collections with no records") + } + for _, phase := range []PhaseOperationalEvidence{b.Phase1, b.Phase2} { + for _, head := range phase.AcceptedHeads { + if head.MirrorReceipts == nil { + return fmt.Errorf("%s head %d requires an explicit mirror_receipts array", phase.Phase, head.Index) + } + } + } + case OperationalEvidenceBundleSchemaV2: + if b.AssurancePolicy != nil { + return errors.New("operational evidence v2 must not contain assurance_policy") + } + default: return fmt.Errorf("operational evidence schema %q is unsupported", b.Schema) } if err := validateHashID("ceremony_id", b.CeremonyID); err != nil { return err } - minimumEnrollments := 6 // coordinator, release signer, auditor, participant, witness, mirror + minimumEnrollments := 2 // coordinator and release signer; definition binding adds roster requirements if len(b.Enrollments) < minimumEnrollments || len(b.Enrollments) > 128 { return fmt.Errorf("enrollments must contain between %d and 128 records", minimumEnrollments) } @@ -181,6 +201,24 @@ func (b OperationalEvidenceBundle) Validate() error { if b.Phase2.Phase != Phase2 { return errors.New("phase2 evidence has wrong phase") } + if b.Schema == OperationalEvidenceBundleSchema { + for _, phase := range []PhaseOperationalEvidence{b.Phase1, b.Phase2} { + if phase.PublicWitnessQuorum != b.AssurancePolicy.PublicWitnessesPerPhase { + return fmt.Errorf("%s public witness quorum does not match assurance_policy", phase.Phase) + } + if b.AssurancePolicy.PublicWitnessesPerPhase == 0 && len(phase.PublicWitnessReceipts) != 0 { + return fmt.Errorf("%s contains witness receipts while witnessing is disabled", phase.Phase) + } + for _, head := range phase.AcceptedHeads { + if len(head.MirrorReceipts) < int(b.AssurancePolicy.MirrorsPerAcceptedHead) { + return fmt.Errorf("%s head %d mirror receipts are below assurance_policy minimum", phase.Phase, head.Index) + } + if b.AssurancePolicy.MirrorsPerAcceptedHead == 0 && len(head.MirrorReceipts) != 0 { + return fmt.Errorf("%s head %d contains mirror receipts while mirrors are disabled", phase.Phase, head.Index) + } + } + } + } if err := validateID("coordinator_id", b.CoordinatorID); err != nil { return err } @@ -289,6 +327,20 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, bundle.CoordinatorKeyID != options.Definition.Coordinator.KeyID { return VerifiedOperationalEvidence{}, errors.New("operational evidence bundle does not bind ceremony coordinator") } + expectedAssurance := defaultAssurancePolicy(options.Definition.Mode) + if options.Definition.Schema == DefinitionSchema { + if bundle.Schema != OperationalEvidenceBundleSchema { + return VerifiedOperationalEvidence{}, errors.New("definition v3 requires operational evidence bundle v3") + } + expectedAssurance = *options.Definition.AssurancePolicy + if bundle.AssurancePolicy == nil || *bundle.AssurancePolicy != expectedAssurance { + return VerifiedOperationalEvidence{}, errors.New("operational evidence assurance_policy does not exactly match signed definition") + } + } else { + if bundle.Schema != OperationalEvidenceBundleSchemaV2 || bundle.AssurancePolicy != nil { + return VerifiedOperationalEvidence{}, errors.New("legacy definition requires legacy operational evidence without an assurance-policy override") + } + } definitionBytes, err := MarshalCanonical(options.Definition) if err != nil { return VerifiedOperationalEvidence{}, err @@ -321,7 +373,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase1, options.Phase1Close, - enrollments, + enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchema, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase1 operational evidence: %w", err) @@ -332,7 +384,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase2, options.Phase2Close, - enrollments, + enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchema, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err) @@ -505,6 +557,8 @@ func verifyPhaseOperationalEvidence( phaseEvidence PhaseOperationalEvidence, authenticated AuthenticatedCloseEvidence, enrollments map[string]EnrollmentRecord, + assurance AssurancePolicy, + legacy bool, ) ([]ArtifactRef, error) { if err := authenticated.Record.Validate(); err != nil { return nil, err @@ -574,6 +628,23 @@ func verifyPhaseOperationalEvidence( if acceptedHeadIDs[len(acceptedHeadIDs)-1] != authenticated.Record.ChainHeadID { return nil, errors.New("authenticated accepted heads do not terminate at close chain head") } + if !legacy && phaseEvidence.PublicWitnessQuorum != assurance.PublicWitnessesPerPhase { + return nil, fmt.Errorf("public witness quorum %d does not match signed assurance policy %d", phaseEvidence.PublicWitnessQuorum, assurance.PublicWitnessesPerPhase) + } + if legacy && phaseEvidence.PublicWitnessQuorum < 1 { + return nil, errors.New("legacy operational evidence requires at least one public witness") + } + if assurance.PublicWitnessesPerPhase == 0 && len(phaseEvidence.PublicWitnessReceipts) != 0 { + return nil, errors.New("public witness receipts are forbidden when witnessing is disabled") + } + for index, head := range phaseEvidence.AcceptedHeads { + if assurance.MirrorsPerAcceptedHead == 0 && len(head.MirrorReceipts) != 0 { + return nil, fmt.Errorf("accepted head %d has mirror receipts while mirrors are disabled", index+1) + } + if len(head.MirrorReceipts) < int(assurance.MirrorsPerAcceptedHead) { + return nil, fmt.Errorf("accepted head %d mirror receipt count %d does not satisfy signed minimum %d", index+1, len(head.MirrorReceipts), assurance.MirrorsPerAcceptedHead) + } + } headRefs, err := verifyAcceptedHeadEvidence( definition, root, diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 6ab7a8d6..75552a5c 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -25,6 +25,18 @@ type operationalBundleFixture struct { witnessKeys map[string]ed25519.PrivateKey } +func TestOperationalBundleV3RejectsNullOptionalCollections(t *testing.T) { + fixture := newOperationalBundleFixtureWithAssurance(t, &AssurancePolicy{}) + fixture.bundle.GovernanceRecords = nil + if err := fixture.bundle.Validate(); err == nil || !strings.Contains(err.Error(), "explicit arrays") { + t.Fatalf("nil collection error = %v", err) + } + fixture.bundle.GovernanceRecords = []SignedArtifactRefs{} + if err := fixture.bundle.Validate(); err != nil { + t.Fatalf("explicit empty collection rejected: %v", err) + } +} + func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { fixture := newOperationalBundleFixture(t) verify := func(f operationalBundleFixture) error { @@ -44,15 +56,7 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { } t.Run("one witness and one mirror per head", func(t *testing.T) { - f := newOperationalBundleFixture(t) - for _, phase := range []*PhaseOperationalEvidence{&f.bundle.Phase1, &f.bundle.Phase2} { - phase.PublicWitnessQuorum = 1 - phase.PublicWitnessReceipts = phase.PublicWitnessReceipts[:1] - for i := range phase.AcceptedHeads { - phase.AcceptedHeads[i].MirrorReceipts = phase.AcceptedHeads[i].MirrorReceipts[:1] - } - } - resignBundle(t, &f) + f := newOperationalBundleFixtureWithAssurance(t, &AssurancePolicy{PublicWitnessesPerPhase: 1, MirrorsPerAcceptedHead: 1, PassingCeremonyAudits: 1}) if err := verify(f); err != nil { t.Fatal(err) } @@ -62,6 +66,18 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Fatal("higher agreed witness quorum was ignored") } }) + t.Run("all optional operational observers disabled", func(t *testing.T) { + f := newOperationalBundleFixtureWithAssurance(t, &AssurancePolicy{}) + if err := verify(f); err != nil { + t.Fatalf("zero-observer bundle rejected: %v", err) + } + + f.bundle.Phase1.PublicWitnessReceipts = []SignedArtifactRefs{f.bundle.Enrollments[0]} + resignInvalidBundle(t, &f) + if err := verify(f); err == nil || !strings.Contains(err.Error(), "while witnessing is disabled") { + t.Fatalf("injected witness evidence error = %v", err) + } + }) t.Run("zero witnesses", func(t *testing.T) { f := newOperationalBundleFixture(t) f.bundle.Phase1.PublicWitnessQuorum = 1 @@ -421,10 +437,30 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { } func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { + return newOperationalBundleFixtureConfigured(t, nil, false) +} + +func newOperationalBundleFixtureWithAssurance(t *testing.T, selected *AssurancePolicy) operationalBundleFixture { + return newOperationalBundleFixtureConfigured(t, selected, false) +} + +func newLegacyOperationalBundleFixture(t *testing.T) operationalBundleFixture { + return newOperationalBundleFixtureConfigured(t, nil, true) +} + +func newOperationalBundleFixtureConfigured(t *testing.T, selected *AssurancePolicy, legacy bool) operationalBundleFixture { t.Helper() definition := adversarialDefinition(t) round42Time, _ := QuicknetRoundTime(42) definition.Mode = ModeRehearsal + assurance := AssurancePolicy{PublicWitnessesPerPhase: 2, MirrorsPerAcceptedHead: 2, PassingCeremonyAudits: 1} + if selected != nil { + assurance = *selected + } + definition.AssurancePolicy = &assurance + if assurance.PassingCeremonyAudits == 0 { + definition.Auditors = []Identity{} + } 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 @@ -440,6 +476,18 @@ func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { if err != nil { t.Fatal(err) } + if legacy { + definition.Schema = DefinitionSchemaV2 + definition.AssurancePolicy = nil + definition.CeremonyID = "" + definition.CeremonyID, err = ComputeCeremonyID(definition) + if err != nil { + t.Fatal(err) + } + if err := definition.Validate(); err != nil { + t.Fatal(err) + } + } definitionBytes, _ := MarshalCanonical(definition) root := t.TempDir() coordinatorKey := adversarialPrivateKey(0x01) @@ -450,6 +498,7 @@ func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { {adversarialIdentity(t, "public-witness-01", 0x91), adversarialPrivateKey(0x91)}, {adversarialIdentity(t, "public-witness-02", 0x92), adversarialPrivateKey(0x92)}, } + witnesses = witnesses[:int(assurance.PublicWitnessesPerPhase)] mirrors := []struct { identity Identity key ed25519.PrivateKey @@ -457,6 +506,7 @@ func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { {adversarialIdentity(t, "mirror-operator-01", 0xa1), adversarialPrivateKey(0xa1)}, {adversarialIdentity(t, "mirror-operator-02", 0xa2), adversarialPrivateKey(0xa2)}, } + mirrors = mirrors[:int(assurance.MirrorsPerAcceptedHead)] type enrollmentInput struct { identity Identity @@ -467,8 +517,9 @@ func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { inputs := []enrollmentInput{ {definition.Coordinator, EnrollmentCoordinator, 1, coordinatorKey}, {definition.ReleaseSigner, EnrollmentReleaseSigner, 1, adversarialPrivateKey(0x02)}, - {definition.Auditors[0], EnrollmentAuditor, 1, adversarialPrivateKey(0x03)}, - {definition.Auditors[1], EnrollmentAuditor, 2, adversarialPrivateKey(0x04)}, + } + for index, auditor := range definition.Auditors { + inputs = append(inputs, enrollmentInput{auditor, EnrollmentAuditor, uint16(index + 1), adversarialPrivateKey(byte(0x03 + index))}) } for index, participant := range definition.Roster { inputs = append(inputs, enrollmentInput{ @@ -526,14 +577,20 @@ func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { coordinatorKey, witnesses, mirrors, ) bundle := OperationalEvidenceBundle{ - Schema: OperationalEvidenceBundleSchema, - CeremonyID: definition.CeremonyID, - Enrollments: enrollments, - Phase1: phase1, - Phase2: phase2, - CoordinatorID: definition.Coordinator.ID, - CoordinatorKeyID: definition.Coordinator.KeyID, - AssembledAt: round42Time.Add(time.Hour).Format(time.RFC3339), + Schema: OperationalEvidenceBundleSchema, + CeremonyID: definition.CeremonyID, + AssurancePolicy: cloneAssurancePolicy(definition.AssurancePolicy), + Enrollments: enrollments, + GovernanceRecords: []SignedArtifactRefs{}, + Phase1: phase1, + Phase2: phase2, + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + AssembledAt: round42Time.Add(time.Hour).Format(time.RFC3339), + } + if legacy { + bundle.Schema = OperationalEvidenceBundleSchemaV2 + bundle.AssurancePolicy = nil } bundleBytes, signatureBytes, err := SignRecord(bundle, definition.Coordinator.KeyID, coordinatorKey) if err != nil { @@ -902,7 +959,7 @@ func buildOperationalPhaseFixture( AcceptedChainPrefix: prefixPair, MirrorReceipts: mirrorPairs, }}, - PublicWitnessQuorum: 2, + PublicWitnessQuorum: uint8(len(witnesses)), PublicWitnessReceipts: witnessPairs, MultiRelayBeaconEvidence: beaconPair, RawBeaconResponses: rawRefs, diff --git a/internal/mpcceremony/operational_prepare.go b/internal/mpcceremony/operational_prepare.go index 5af36dc0..4eff08f8 100644 --- a/internal/mpcceremony/operational_prepare.go +++ b/internal/mpcceremony/operational_prepare.go @@ -23,9 +23,16 @@ type discoveredOperational struct { } func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt string) (OperationalPreparation, error) { + bundleSchema := OperationalEvidenceBundleSchema + bundleAssurance := cloneAssurancePolicy(definition.AssurancePolicy) + if definition.Schema != DefinitionSchema { + bundleSchema = OperationalEvidenceBundleSchemaV2 + bundleAssurance = nil + } result := OperationalPreparation{Bundle: OperationalEvidenceBundle{ - Schema: OperationalEvidenceBundleSchema, CeremonyID: definition.CeremonyID, - CoordinatorID: definition.Coordinator.ID, CoordinatorKeyID: definition.Coordinator.KeyID, + Schema: bundleSchema, CeremonyID: definition.CeremonyID, + AssurancePolicy: bundleAssurance, + CoordinatorID: definition.Coordinator.ID, CoordinatorKeyID: definition.Coordinator.KeyID, AssembledAt: assembledAt, Enrollments: []SignedArtifactRefs{}, GovernanceRecords: []SignedArtifactRefs{}, }, Missing: []string{}} if err := definition.Validate(); err != nil { @@ -34,6 +41,10 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt if err := validateTimestamp("assembled_at", assembledAt); err != nil { return result, err } + assurance := defaultAssurancePolicy(definition.Mode) + if definition.Schema == DefinitionSchema { + assurance = *definition.AssurancePolicy + } var records []discoveredOperational signatures := map[string][]ArtifactRef{} seen := map[string]bool{} @@ -179,7 +190,7 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt } } for _, phase := range []Phase{Phase1, Phase2} { - p := PhaseOperationalEvidence{Phase: phase, PublicWitnessQuorum: 1, AcceptedHeads: []AcceptedHeadOperationalEvidence{}, RawBeaconResponses: []ArtifactRef{}} + p := PhaseOperationalEvidence{Phase: phase, PublicWitnessQuorum: assurance.PublicWitnessesPerPhase, AcceptedHeads: []AcceptedHeadOperationalEvidence{}, PublicWitnessReceipts: []SignedArtifactRefs{}, RawBeaconResponses: []ArtifactRef{}} label := string(phase) closePair, closeAny := pick(label+" closure", func(v any) bool { c, ok := v.(*CloseRecord); return ok && c.Phase == phase }) p.Close = closePair @@ -196,8 +207,8 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt w, ok := v.(*PublicWitnessReceipt) return ok && w.Phase == phase && w.CloseID == close.CloseID }) - if len(p.PublicWitnessReceipts) < 1 { - result.Missing = append(result.Missing, label+": collect signed observations from at least one witness; expired windows cannot be recreated") + if len(p.PublicWitnessReceipts) < int(assurance.PublicWitnessesPerPhase) { + result.Missing = append(result.Missing, fmt.Sprintf("%s: collect %d signed witness observations; expired windows cannot be recreated", label, assurance.PublicWitnessesPerPhase)) } p.MultiRelayBeaconEvidence, closeAny = pick(label+" two-operator beacon evidence", func(v any) bool { b, ok := v.(*MultiRelayBeaconEvidence) @@ -260,8 +271,8 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt } break } - if len(h.MirrorReceipts) < 1 { - result.Missing = append(result.Missing, scope+": collect at least one signed mirror receipt for this exact head") + if len(h.MirrorReceipts) < int(assurance.MirrorsPerAcceptedHead) { + result.Missing = append(result.Missing, fmt.Sprintf("%s: collect %d signed mirror receipts for this exact head", scope, assurance.MirrorsPerAcceptedHead)) } p.AcceptedHeads = append(p.AcceptedHeads, h) } diff --git a/internal/mpcceremony/single_auditor_test.go b/internal/mpcceremony/single_auditor_test.go index 65e1cf2d..9c8a7ecc 100644 --- a/internal/mpcceremony/single_auditor_test.go +++ b/internal/mpcceremony/single_auditor_test.go @@ -1,11 +1,19 @@ package mpcceremony -import "testing" +import ( + "strings" + "testing" +) func TestSingleAuditorMinimumBothModes(t *testing.T) { for _, mode := range []string{ModeRehearsal, ModeProduction} { d := adversarialDefinition(t) d.Mode = mode + if mode == ModeRehearsal { + assurance := *d.AssurancePolicy + assurance.ExternalSecurityAuditSignoffs = 0 + d.AssurancePolicy = &assurance + } d.Auditors = d.Auditors[:1] if _, err := FinalizeCeremonyDefinition(d); err != nil { t.Fatalf("%s one auditor: %v", mode, err) @@ -19,8 +27,8 @@ func TestSingleAuditorMinimumBothModes(t *testing.T) { t.Fatal("zero auditors accepted") } p.Auditors = nil - if err := p.Validate(); err == nil { - t.Fatal("zero auditors in init accepted") + if err := p.Validate(); err != nil { + t.Fatalf("zero-auditor roster must remain representable until signed policy validation: %v", err) } } } @@ -44,3 +52,124 @@ func TestProductionDecisionOneAuditMinimum(t *testing.T) { t.Fatal("zero external audits accepted") } } + +func TestProductionDecisionV2HonorsDisabledAssuranceControls(t *testing.T) { + legacy := newProductionDecisionFixture(t, DecisionGO) + definition := adversarialDefinition(t) + definition.CeremonyID = "" + definition.Auditors = nil + definition.AssurancePolicy = &AssurancePolicy{} + definition, err := FinalizeCeremonyDefinition(definition) + if err != nil { + t.Fatal(err) + } + + value := legacy.decision + value.Gates = append([]ProductionGateResult(nil), legacy.decision.Gates...) + value.Schema = "" + value.DecisionID = "" + value.CeremonyID = definition.CeremonyID + value.AssurancePolicy = &AssurancePolicy{} + value.Audits = nil + value.ExternalAudits = nil + for index := range value.Gates { + switch value.Gates[index].Gate { + case GateIndependentAudits, GateExternalAudit, GatePublicWitnessing, GateImmutableMirrors: + value.Gates[index].Status = GateNotRequired + value.Gates[index].Evidence = nil + value.Gates[index].Rationale = "Disabled by the signed assurance policy." + } + } + decision, err := NewProductionDecision(value) + if err != nil { + t.Fatal(err) + } + if err := validateProductionDecisionBinding(definition, decision); err != nil { + t.Fatalf("all-optional decision rejected: %v", err) + } + + injected := decision + injected.DecisionID = "" + injected.Audits = legacy.decision.Audits[:1] + if _, err = NewProductionDecision(injected); err == nil { + t.Fatal("audit evidence accepted while ceremony audits were disabled") + } + + wrongGate := decision + wrongGate.DecisionID = "" + for index := range wrongGate.Gates { + if wrongGate.Gates[index].Gate == GatePublicWitnessing { + wrongGate.Gates[index] = legacy.decision.Gates[index] + } + } + if _, err = NewProductionDecision(wrongGate); err == nil { + t.Fatal("enabled-looking witness gate accepted while witnessing was disabled") + } + + mandatory := decision + mandatory.DecisionID = "" + mandatory.Gates = append([]ProductionGateResult(nil), decision.Gates...) + mandatory.Gates[0] = ProductionGateResult{Gate: GateSignedRelease, Status: GateNotRequired, Rationale: "Attempted bypass."} + if _, err := NewProductionDecision(mandatory); err == nil { + t.Fatal("mandatory signed-release gate accepted NOT_REQUIRED") + } +} + +func TestLegacyDecisionCannotDisableOldMandatoryGates(t *testing.T) { + fixture := newProductionDecisionFixture(t, DecisionGO) + value := fixture.decision + value.Schema = ProductionDecisionSchemaV1 + value.AssurancePolicy = nil + value.DecisionID = "" + for index := range value.Gates { + if value.Gates[index].Gate == GatePublicWitnessing { + value.Gates[index].Status = GateNotRequired + value.Gates[index].Evidence = nil + value.Gates[index].Rationale = "Disabled." + } + } + if _, err := NewProductionDecision(value); err == nil || !strings.Contains(err.Error(), "cannot be NOT_REQUIRED") { + t.Fatalf("legacy NOT_REQUIRED error = %v", err) + } +} + +func TestFinalTranscriptAndAuditVerifierHonorZeroAuditPolicy(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + definition.Auditors = nil + policy := *definition.AssurancePolicy + policy.PassingCeremonyAudits = 0 + definition.AssurancePolicy = &policy + definition, err := FinalizeCeremonyDefinition(definition) + if err != nil { + t.Fatal(err) + } + candidate := adversarialCandidate(t, definition) + if refs, latest, err := verifyPassingAudits(definition, candidate, nil); err != nil || len(refs) != 0 || !latest.IsZero() { + t.Fatalf("zero-audit verification = refs %v latest %v err %v", refs, latest, err) + } + + transcript, err := NewFinalTranscript(FinalTranscript{ + CeremonyID: definition.CeremonyID, + AssurancePolicy: cloneAssurancePolicy(definition.AssurancePolicy), + Definition: candidate.Definition, + Circuit: definition.Circuit, + Phase1: candidate.Phase1, + Phase2: candidate.Phase2, + Audits: []ArtifactRef{}, + OperationalEvidence: SignedArtifactRefs{ + Record: ArtifactRef{Name: "operational/evidence-bundle.json", Digest: NewDigest([]byte("bundle"))}, + Signature: ArtifactRef{Name: "operational/evidence-bundle.sig", Digest: NewDigest([]byte("signature"))}, + }, + ProvingKey: candidate.ProvingKey, + VerifyingKey: candidate.VerifyingKey, + CardanoVerifyingKey: candidate.CardanoVerifyingKey, + FinalizedAt: "2026-07-23T16:00:00Z", + }) + if err != nil { + t.Fatalf("zero-audit final transcript rejected: %v", err) + } + if transcript.Schema != FinalTranscriptSchema || transcript.AssurancePolicy == nil { + t.Fatal("zero-audit final transcript did not use policy-bound v2 schema") + } +} diff --git a/internal/mpcceremony/software.go b/internal/mpcceremony/software.go index ef687de1..60836281 100644 --- a/internal/mpcceremony/software.go +++ b/internal/mpcceremony/software.go @@ -82,39 +82,9 @@ func SoftwareBindingWithAllowedBinaryFiles( ) (SoftwareBinding, error) { bindings := make([]SoftwareBinding, 0, len(paths)) for index, path := range paths { - file, err := os.Open(path) + binding, err := SoftwareBindingFromExecutableFileForMode(path, proofToolVersion, mode) if err != nil { - return SoftwareBinding{}, fmt.Errorf("open allowed binary %d %q: %w", index, path, err) - } - info, err := buildinfo.Read(file) - if err != nil { - file.Close() - return SoftwareBinding{}, fmt.Errorf("read allowed binary %d %q build info: %w", index, path, err) - } - if info.Path != "proof-tool/cmd/mpc-ceremony" { - file.Close() - return SoftwareBinding{}, fmt.Errorf( - "allowed binary %d %q main package %q, want %q", - index, path, info.Path, "proof-tool/cmd/mpc-ceremony", - ) - } - fdPath, err := openFileDescriptorPath(file) - if err != nil { - file.Close() - return SoftwareBinding{}, fmt.Errorf("allowed binary %d %q: %w", index, path, err) - } - source := runningSoftwareSource{ - executable: func() (string, error) { return fdPath, nil }, - readBuildInfo: func() (*debug.BuildInfo, bool) { return info, true }, - runtimeVersion: func() string { return info.GoVersion }, - } - binding, bindingErr := runningSoftwareBinding(proofToolVersion, mode, source) - closeErr := file.Close() - if bindingErr != nil { - return SoftwareBinding{}, fmt.Errorf("authenticate allowed binary %d %q: %w", index, path, bindingErr) - } - if closeErr != nil { - return SoftwareBinding{}, fmt.Errorf("close allowed binary %d %q: %w", index, path, closeErr) + return SoftwareBinding{}, fmt.Errorf("authenticate allowed binary %d %q: %w", index, path, err) } if err := requireCommonSoftwareIdentity(primary, binding); err != nil { return SoftwareBinding{}, fmt.Errorf("allowed binary %d %q: %w", index, path, err) @@ -124,6 +94,44 @@ func SoftwareBindingWithAllowedBinaryFiles( return softwareBindingWithAllowedBindings(primary, bindings) } +// SoftwareBindingFromExecutableFileForMode authenticates one mpc-ceremony +// executable without running it. Callers must supply the expected release +// version and ceremony mode; both remain part of the validated binding. +func SoftwareBindingFromExecutableFileForMode(path, proofToolVersion, mode string) (SoftwareBinding, error) { + file, err := os.Open(path) + if err != nil { + return SoftwareBinding{}, fmt.Errorf("open executable %q: %w", path, err) + } + info, err := buildinfo.Read(file) + if err != nil { + file.Close() + return SoftwareBinding{}, fmt.Errorf("read executable %q build info: %w", path, err) + } + if info.Path != "proof-tool/cmd/mpc-ceremony" { + file.Close() + return SoftwareBinding{}, fmt.Errorf("executable %q main package %q, want %q", path, info.Path, "proof-tool/cmd/mpc-ceremony") + } + fdPath, err := openFileDescriptorPath(file) + if err != nil { + file.Close() + return SoftwareBinding{}, err + } + source := runningSoftwareSource{ + executable: func() (string, error) { return fdPath, nil }, + readBuildInfo: func() (*debug.BuildInfo, bool) { return info, true }, + runtimeVersion: func() string { return info.GoVersion }, + } + binding, bindingErr := runningSoftwareBinding(proofToolVersion, mode, source) + closeErr := file.Close() + if bindingErr != nil { + return SoftwareBinding{}, bindingErr + } + if closeErr != nil { + return SoftwareBinding{}, fmt.Errorf("close executable %q: %w", path, closeErr) + } + return binding, nil +} + func softwareBindingWithAllowedBindings( primary SoftwareBinding, additional []SoftwareBinding, diff --git a/internal/mpcceremony/submission.go b/internal/mpcceremony/submission.go new file mode 100644 index 00000000..b8079ad6 --- /dev/null +++ b/internal/mpcceremony/submission.go @@ -0,0 +1,375 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "errors" + "fmt" + "strings" +) + +const ( + SubmissionEnvelopeSchemaV1 = "proof-tool-mpc-submission-envelope-v1" + SubmissionAcknowledgementSchemaV1 = "proof-tool-mpc-submission-acknowledgement-v1" + SubmissionRoleParticipant = "participant" +) + +type SubmissionAcknowledgementResult string + +const ( + SubmissionAccepted SubmissionAcknowledgementResult = "accepted" + SubmissionRejected SubmissionAcknowledgementResult = "rejected" +) + +// SubmissionEnvelopeV1 is the participant-authored, authenticated meaning of +// an inbox submission. The object-store manifest is only transport framing. +type SubmissionEnvelopeV1 struct { + Schema string `json:"schema"` + Workflow string `json:"workflow"` + CeremonyID string `json:"ceremony_id"` + Definition SignedArtifactRefs `json:"definition"` + RelayReleaseID string `json:"relay_release_id"` + SubmitterID string `json:"submitter_id"` + SubmitterKeyID string `json:"submitter_key_id"` + SubmitterRole string `json:"submitter_role"` + Kind CheckpointSubmissionKind `json:"kind"` + Phase Phase `json:"phase"` + Index uint8 `json:"index"` + ParentCheckpointSHA256 string `json:"parent_checkpoint_sha256"` + AllocationCheckpointSHA256 string `json:"allocation_checkpoint_sha256"` + ParentHeadID string `json:"parent_head_id"` + AttemptID string `json:"attempt_id"` + ManifestKey string `json:"manifest_key"` + Payloads []ArtifactRef `json:"payloads"` +} + +func (e SubmissionEnvelopeV1) Validate() error { + if e.Schema != SubmissionEnvelopeSchemaV1 { + return fmt.Errorf("submission envelope schema %q, want %q", e.Schema, SubmissionEnvelopeSchemaV1) + } + if e.Workflow != StorageFirstWorkflowV1 { + return fmt.Errorf("submission workflow %q, want %q", e.Workflow, StorageFirstWorkflowV1) + } + if err := validateHashID("ceremony_id", e.CeremonyID); err != nil { + return err + } + if err := e.Definition.Validate(); err != nil { + return fmt.Errorf("definition: %w", err) + } + if err := validateHashID("allocation_checkpoint_sha256", e.AllocationCheckpointSHA256); err != nil { + return err + } + if err := validateID("relay_release_id", e.RelayReleaseID); err != nil { + return err + } + if err := validateID("submitter_id", e.SubmitterID); err != nil { + return err + } + if err := validateID("submitter_key_id", e.SubmitterKeyID); err != nil { + return err + } + if e.SubmitterRole != SubmissionRoleParticipant { + return fmt.Errorf("submitter_role %q, want %q", e.SubmitterRole, SubmissionRoleParticipant) + } + slot := CheckpointSubmissionSlot{Kind: e.Kind, Phase: e.Phase, Index: e.Index, + IdentityID: e.SubmitterID, AttemptID: e.AttemptID, ManifestKey: e.ManifestKey, + BasisCheckpointSHA256: e.ParentCheckpointSHA256, ParentHeadID: e.ParentHeadID, + Status: CheckpointSubmissionAllocated} + if err := slot.Validate(); err != nil { + return fmt.Errorf("submission scope: %w", err) + } + if len(e.Payloads) == 0 || len(e.Payloads) > 64 { + return errors.New("payloads must contain between 1 and 64 entries") + } + for i, payload := range e.Payloads { + if err := payload.Validate(); err != nil { + return fmt.Errorf("payloads %d: %w", i, err) + } + if payload.Name == e.ManifestKey { + return errors.New("payload inventory must not contain its transport manifest") + } + if i > 0 && e.Payloads[i-1].Name >= payload.Name { + return errors.New("payloads must be strictly sorted by unique name") + } + } + return nil +} + +// ValidateSubmissionEnvelopeBinding proves that the envelope occupies exactly +// one coordinator-preallocated slot in this checkpoint and frozen definition. +func ValidateSubmissionEnvelopeBinding(definition CeremonyDefinition, checkpoint Checkpoint, slot CheckpointSubmissionSlot, envelope SubmissionEnvelopeV1) error { + if err := definition.Validate(); err != nil { + return fmt.Errorf("definition: %w", err) + } + if err := checkpoint.Validate(); err != nil { + return fmt.Errorf("checkpoint: %w", err) + } + if err := envelope.Validate(); err != nil { + return err + } + if checkpoint.CeremonyID != definition.CeremonyID || envelope.CeremonyID != definition.CeremonyID { + return errors.New("submission ceremony does not match the signed definition and checkpoint") + } + if envelope.Workflow != checkpoint.Workflow || envelope.Definition != checkpoint.Definition || envelope.RelayReleaseID != checkpoint.RelayReleaseID { + return errors.New("submission workflow, definition, or release binding does not match checkpoint") + } + if err := slot.Validate(); err != nil { + return fmt.Errorf("slot: %w", err) + } + if slot.Status != CheckpointSubmissionAllocated { + return errors.New("submission slot is not allocated") + } + found := false + for _, candidate := range checkpoint.Submissions { + if candidate == slot { + found = true + break + } + } + if !found { + return errors.New("submission slot is not present exactly in checkpoint") + } + if envelope.Kind != slot.Kind || envelope.Phase != slot.Phase || envelope.Index != slot.Index || + envelope.SubmitterID != slot.IdentityID || envelope.AttemptID != slot.AttemptID || + envelope.ManifestKey != slot.ManifestKey || envelope.ParentCheckpointSHA256 != slot.BasisCheckpointSHA256 || + envelope.ParentHeadID != slot.ParentHeadID { + return errors.New("submission envelope does not match its exact allocated slot") + } + checkpointBytes, err := MarshalCanonical(checkpoint) + if err != nil { + return fmt.Errorf("canonical checkpoint: %w", err) + } + if envelope.AllocationCheckpointSHA256 != NewDigest(checkpointBytes).SHA256 { + return errors.New("submission envelope does not bind the exact checkpoint that allocated its slot") + } + participant, ok := definition.ParticipantByID(slot.IdentityID) + if !ok { + return errors.New("submission slot identity is not a participant in the signed definition") + } + if envelope.SubmitterKeyID != participant.Identity.KeyID { + return errors.New("submission key does not match assigned participant") + } + policy, err := definition.PolicyForPhase(slot.Phase) + if err != nil { + return err + } + if int(slot.Index) > len(policy.Participants) || policy.Participants[slot.Index-1] != slot.IdentityID { + return errors.New("submission slot does not match the signed participant order") + } + return nil +} + +func SignSubmissionEnvelope(definition CeremonyDefinition, checkpoint Checkpoint, slot CheckpointSubmissionSlot, envelope SubmissionEnvelopeV1, privateKey ed25519.PrivateKey) ([]byte, []byte, error) { + if err := ValidateSubmissionEnvelopeBinding(definition, checkpoint, slot, envelope); err != nil { + return nil, nil, err + } + participant, _ := definition.ParticipantByID(envelope.SubmitterID) + if err := privateKeyMatchesIdentity(privateKey, participant.Identity); err != nil { + return nil, nil, err + } + return SignRecord(envelope, envelope.SubmitterKeyID, privateKey) +} + +func VerifySignedSubmissionEnvelope(definition CeremonyDefinition, checkpoint Checkpoint, slot CheckpointSubmissionSlot, recordBytes, signatureBytes []byte) (SubmissionEnvelopeV1, error) { + participant, ok := definition.ParticipantByID(slot.IdentityID) + if !ok { + return SubmissionEnvelopeV1{}, errors.New("submission slot identity is not a participant in the signed definition") + } + publicKey, err := identityPublicKey(participant.Identity) + if err != nil { + return SubmissionEnvelopeV1{}, err + } + var envelope SubmissionEnvelopeV1 + if err := VerifySignedRecord(recordBytes, signatureBytes, &envelope, participant.Identity.KeyID, publicKey); err != nil { + return SubmissionEnvelopeV1{}, fmt.Errorf("submission envelope signature: %w", err) + } + if err := ValidateSubmissionEnvelopeBinding(definition, checkpoint, slot, envelope); err != nil { + return SubmissionEnvelopeV1{}, err + } + return envelope, nil +} + +// SubmissionAcknowledgementV1 is coordinator-authored. It binds the exact +// participant envelope and exact manifest bytes to one accepted/rejected result. +type SubmissionAcknowledgementV1 struct { + Schema string `json:"schema"` + Workflow string `json:"workflow"` + CeremonyID string `json:"ceremony_id"` + Definition SignedArtifactRefs `json:"definition"` + RelayReleaseID string `json:"relay_release_id"` + CoordinatorID string `json:"coordinator_id"` + CoordinatorKeyID string `json:"coordinator_key_id"` + SubmitterID string `json:"submitter_id"` + SubmitterKeyID string `json:"submitter_key_id"` + SubmitterRole string `json:"submitter_role"` + Kind CheckpointSubmissionKind `json:"kind"` + Phase Phase `json:"phase"` + Index uint8 `json:"index"` + ParentCheckpointSHA256 string `json:"parent_checkpoint_sha256"` + AllocationCheckpointSHA256 string `json:"allocation_checkpoint_sha256"` + ParentHeadID string `json:"parent_head_id"` + AttemptID string `json:"attempt_id"` + ManifestKey string `json:"manifest_key"` + Envelope SignedArtifactRefs `json:"envelope"` + Manifest ArtifactRef `json:"manifest"` + Result SubmissionAcknowledgementResult `json:"result"` + ReasonCode string `json:"reason_code,omitempty"` +} + +func (a SubmissionAcknowledgementV1) Validate() error { + if a.Schema != SubmissionAcknowledgementSchemaV1 { + return fmt.Errorf("submission acknowledgement schema %q, want %q", a.Schema, SubmissionAcknowledgementSchemaV1) + } + if a.Workflow != StorageFirstWorkflowV1 { + return fmt.Errorf("submission workflow %q, want %q", a.Workflow, StorageFirstWorkflowV1) + } + if err := validateHashID("ceremony_id", a.CeremonyID); err != nil { + return err + } + if err := a.Definition.Validate(); err != nil { + return fmt.Errorf("definition: %w", err) + } + if err := validateHashID("allocation_checkpoint_sha256", a.AllocationCheckpointSHA256); err != nil { + return err + } + if err := validateID("relay_release_id", a.RelayReleaseID); err != nil { + return err + } + if err := validateID("coordinator_id", a.CoordinatorID); err != nil { + return err + } + if err := validateID("coordinator_key_id", a.CoordinatorKeyID); err != nil { + return err + } + if err := validateID("submitter_id", a.SubmitterID); err != nil { + return err + } + if err := validateID("submitter_key_id", a.SubmitterKeyID); err != nil { + return err + } + if a.SubmitterRole != SubmissionRoleParticipant { + return fmt.Errorf("submitter_role %q, want %q", a.SubmitterRole, SubmissionRoleParticipant) + } + slot := CheckpointSubmissionSlot{Kind: a.Kind, Phase: a.Phase, Index: a.Index, IdentityID: a.SubmitterID, + AttemptID: a.AttemptID, ManifestKey: a.ManifestKey, BasisCheckpointSHA256: a.ParentCheckpointSHA256, + ParentHeadID: a.ParentHeadID, Status: CheckpointSubmissionAllocated} + if err := slot.Validate(); err != nil { + return fmt.Errorf("submission scope: %w", err) + } + if err := a.Envelope.Validate(); err != nil { + return fmt.Errorf("envelope: %w", err) + } + if err := a.Manifest.Validate(); err != nil { + return fmt.Errorf("manifest: %w", err) + } + if a.Manifest.Name != a.ManifestKey { + return errors.New("manifest reference name does not match preallocated manifest key") + } + switch a.Result { + case SubmissionAccepted: + if a.ReasonCode != "" { + return errors.New("accepted acknowledgement must not contain a reason_code") + } + case SubmissionRejected: + if err := validateReasonCode(a.ReasonCode); err != nil { + return err + } + default: + return fmt.Errorf("unsupported acknowledgement result %q", a.Result) + } + return nil +} + +func ValidateSubmissionAcknowledgementBinding(definition CeremonyDefinition, checkpoint Checkpoint, slot CheckpointSubmissionSlot, envelope SubmissionEnvelopeV1, envelopeRefs SignedArtifactRefs, manifest ArtifactRef, acknowledgement SubmissionAcknowledgementV1) error { + if err := ValidateSubmissionEnvelopeBinding(definition, checkpoint, slot, envelope); err != nil { + return err + } + if err := acknowledgement.Validate(); err != nil { + return err + } + if acknowledgement.Workflow != envelope.Workflow || acknowledgement.CeremonyID != envelope.CeremonyID || + acknowledgement.Definition != envelope.Definition || acknowledgement.RelayReleaseID != envelope.RelayReleaseID || + acknowledgement.SubmitterID != envelope.SubmitterID || acknowledgement.SubmitterKeyID != envelope.SubmitterKeyID || + acknowledgement.SubmitterRole != envelope.SubmitterRole || acknowledgement.Kind != envelope.Kind || + acknowledgement.Phase != envelope.Phase || acknowledgement.Index != envelope.Index || + acknowledgement.ParentCheckpointSHA256 != envelope.ParentCheckpointSHA256 || acknowledgement.ParentHeadID != envelope.ParentHeadID || + acknowledgement.AllocationCheckpointSHA256 != envelope.AllocationCheckpointSHA256 || + acknowledgement.AttemptID != envelope.AttemptID || acknowledgement.ManifestKey != envelope.ManifestKey { + return errors.New("acknowledgement does not bind the exact submission envelope scope") + } + if acknowledgement.CoordinatorID != definition.Coordinator.ID || acknowledgement.CoordinatorKeyID != definition.Coordinator.KeyID { + return errors.New("acknowledgement coordinator does not match the signed definition") + } + if acknowledgement.Envelope != envelopeRefs { + return errors.New("acknowledgement does not bind the exact signed envelope bytes") + } + if acknowledgement.Manifest != manifest { + return errors.New("acknowledgement does not bind the exact manifest bytes") + } + return nil +} + +func SignSubmissionAcknowledgement(definition CeremonyDefinition, checkpoint Checkpoint, slot CheckpointSubmissionSlot, envelope SubmissionEnvelopeV1, envelopeRefs SignedArtifactRefs, manifest ArtifactRef, acknowledgement SubmissionAcknowledgementV1, privateKey ed25519.PrivateKey) ([]byte, []byte, error) { + if err := ValidateSubmissionAcknowledgementBinding(definition, checkpoint, slot, envelope, envelopeRefs, manifest, acknowledgement); err != nil { + return nil, nil, err + } + if err := privateKeyMatchesIdentity(privateKey, definition.Coordinator); err != nil { + return nil, nil, err + } + return SignRecord(acknowledgement, acknowledgement.CoordinatorKeyID, privateKey) +} + +// VerifySignedSubmissionAcknowledgement authenticates both signature roles and +// derives every acknowledgement digest from the supplied exact bytes. +func VerifySignedSubmissionAcknowledgement(definition CeremonyDefinition, checkpoint Checkpoint, slot CheckpointSubmissionSlot, + envelopeName, envelopeSignatureName string, envelopeBytes, envelopeSignatureBytes []byte, + manifestName string, manifestBytes, acknowledgementBytes, acknowledgementSignatureBytes []byte) (SubmissionAcknowledgementV1, error) { + envelope, err := VerifySignedSubmissionEnvelope(definition, checkpoint, slot, envelopeBytes, envelopeSignatureBytes) + if err != nil { + return SubmissionAcknowledgementV1{}, err + } + envelopeRefs := SignedArtifactRefs{ + Record: ArtifactRef{Name: envelopeName, Digest: NewDigest(envelopeBytes)}, + Signature: ArtifactRef{Name: envelopeSignatureName, Digest: NewDigest(envelopeSignatureBytes)}, + } + manifest := ArtifactRef{Name: manifestName, Digest: NewDigest(manifestBytes)} + publicKey, err := identityPublicKey(definition.Coordinator) + if err != nil { + return SubmissionAcknowledgementV1{}, err + } + var acknowledgement SubmissionAcknowledgementV1 + if err := VerifySignedRecord(acknowledgementBytes, acknowledgementSignatureBytes, &acknowledgement, definition.Coordinator.KeyID, publicKey); err != nil { + return SubmissionAcknowledgementV1{}, fmt.Errorf("submission acknowledgement signature: %w", err) + } + if err := ValidateSubmissionAcknowledgementBinding(definition, checkpoint, slot, envelope, envelopeRefs, manifest, acknowledgement); err != nil { + return SubmissionAcknowledgementV1{}, err + } + return acknowledgement, nil +} + +func privateKeyMatchesIdentity(privateKey ed25519.PrivateKey, identity Identity) error { + if len(privateKey) != ed25519.PrivateKeySize { + return fmt.Errorf("Ed25519 private key is %d bytes, want %d", len(privateKey), ed25519.PrivateKeySize) + } + publicKey, err := identityPublicKey(identity) + if err != nil { + return err + } + derived, ok := privateKey.Public().(ed25519.PublicKey) + if !ok || !bytes.Equal(derived, publicKey) { + return fmt.Errorf("private key does not match identity %q", identity.ID) + } + return nil +} + +func validateReasonCode(value string) error { + if value == "" || len(value) > 64 || value != strings.TrimSpace(value) { + return errors.New("rejected acknowledgement requires a short reason_code") + } + for _, r := range value { + if (r < 'a' || r > 'z') && (r < '0' || r > '9') && r != '-' { + return errors.New("reason_code must use lowercase letters, numbers, and hyphens") + } + } + return nil +} diff --git a/internal/mpcceremony/submission_test.go b/internal/mpcceremony/submission_test.go new file mode 100644 index 00000000..e7ed6a07 --- /dev/null +++ b/internal/mpcceremony/submission_test.go @@ -0,0 +1,186 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "strings" + "testing" +) + +func submissionFixture(t *testing.T) (CeremonyDefinition, Checkpoint, CheckpointSubmissionSlot, SubmissionEnvelopeV1, ed25519.PrivateKey, ed25519.PrivateKey, []byte) { + t.Helper() + definition := adversarialDefinition(t) + definitionBytes, definitionSignatureBytes, err := SignRecord(definition, definition.Coordinator.KeyID, adversarialPrivateKey(0x01)) + if err != nil { + t.Fatal(err) + } + definitionRefs := SignedArtifactRefs{ + Record: ArtifactRef{Name: "definition/ceremony.json", Digest: NewDigest(definitionBytes)}, + Signature: ArtifactRef{Name: "definition/ceremony.sig", Digest: NewDigest(definitionSignatureBytes)}, + } + checkpoint := phase1CheckpointSequence(t)[1] + oldDefinition := checkpoint.Definition + checkpoint.CeremonyID = definition.CeremonyID + checkpoint.Definition = definitionRefs + for i, ref := range checkpoint.AcceptedArtifacts { + switch ref { + case oldDefinition.Record: + checkpoint.AcceptedArtifacts[i] = definitionRefs.Record + case oldDefinition.Signature: + checkpoint.AcceptedArtifacts[i] = definitionRefs.Signature + } + } + checkpoint.AcceptedArtifacts = checkpointArtifacts(checkpoint.AcceptedArtifacts...) + slot := checkpoint.Submissions[0] + participant := definition.Roster[0].Identity + slot.IdentityID = participant.ID + checkpoint.Submissions[0] = slot + checkpoint.Transition.ParticipantID = participant.ID + if err := checkpoint.Validate(); err != nil { + t.Fatalf("checkpoint fixture: %v", err) + } + checkpointBytes, err := MarshalCanonical(checkpoint) + if err != nil { + t.Fatal(err) + } + envelope := SubmissionEnvelopeV1{ + Schema: SubmissionEnvelopeSchemaV1, Workflow: checkpoint.Workflow, + CeremonyID: definition.CeremonyID, Definition: definitionRefs, RelayReleaseID: checkpoint.RelayReleaseID, + SubmitterID: participant.ID, SubmitterKeyID: participant.KeyID, SubmitterRole: SubmissionRoleParticipant, + Kind: slot.Kind, Phase: slot.Phase, Index: slot.Index, + ParentCheckpointSHA256: slot.BasisCheckpointSHA256, AllocationCheckpointSHA256: NewDigest(checkpointBytes).SHA256, ParentHeadID: slot.ParentHeadID, + AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, + Payloads: []ArtifactRef{ + checkpointArtifact("submissions/receipt/"+slot.AttemptID+"/handoff-receipt.json", "receipt"), + checkpointArtifact("submissions/receipt/"+slot.AttemptID+"/handoff-receipt.sig", "receipt signature"), + }, + } + return definition, checkpoint, slot, envelope, adversarialPrivateKey(0x11), adversarialPrivateKey(0x01), []byte(`{"files":["handoff-receipt.json","handoff-receipt.sig"]}`) +} + +func TestSubmissionEnvelopeAndAcknowledgementExactBinding(t *testing.T) { + definition, checkpoint, slot, envelope, participantKey, coordinatorKey, manifestBytes := submissionFixture(t) + envelopeBytes, envelopeSignatureBytes, err := SignSubmissionEnvelope(definition, checkpoint, slot, envelope, participantKey) + if err != nil { + t.Fatalf("sign envelope: %v", err) + } + verifiedEnvelope, err := VerifySignedSubmissionEnvelope(definition, checkpoint, slot, envelopeBytes, envelopeSignatureBytes) + if err != nil { + t.Fatalf("verify envelope: %v", err) + } + envelopeRefs := SignedArtifactRefs{ + Record: ArtifactRef{Name: "submissions/receipt/envelope.json", Digest: NewDigest(envelopeBytes)}, + Signature: ArtifactRef{Name: "submissions/receipt/envelope.sig", Digest: NewDigest(envelopeSignatureBytes)}, + } + manifest := ArtifactRef{Name: slot.ManifestKey, Digest: NewDigest(manifestBytes)} + ack := SubmissionAcknowledgementV1{ + Schema: SubmissionAcknowledgementSchemaV1, Workflow: envelope.Workflow, CeremonyID: envelope.CeremonyID, + Definition: envelope.Definition, RelayReleaseID: envelope.RelayReleaseID, + CoordinatorID: definition.Coordinator.ID, CoordinatorKeyID: definition.Coordinator.KeyID, + SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, + Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, ParentHeadID: envelope.ParentHeadID, + AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Envelope: envelopeRefs, Manifest: manifest, Result: SubmissionAccepted, + } + ackBytes, ackSignatureBytes, err := SignSubmissionAcknowledgement(definition, checkpoint, slot, verifiedEnvelope, envelopeRefs, manifest, ack, coordinatorKey) + if err != nil { + t.Fatalf("sign acknowledgement: %v", err) + } + verified, err := VerifySignedSubmissionAcknowledgement(definition, checkpoint, slot, + envelopeRefs.Record.Name, envelopeRefs.Signature.Name, envelopeBytes, envelopeSignatureBytes, + manifest.Name, manifestBytes, ackBytes, ackSignatureBytes) + if err != nil { + t.Fatalf("verify acknowledgement: %v", err) + } + if verified.Result != SubmissionAccepted { + t.Fatalf("result = %q", verified.Result) + } + + if _, err := VerifySignedSubmissionAcknowledgement(definition, checkpoint, slot, + envelopeRefs.Record.Name, envelopeRefs.Signature.Name, envelopeBytes, envelopeSignatureBytes, + manifest.Name, append(manifestBytes, 'x'), ackBytes, ackSignatureBytes); err == nil { + t.Fatal("changed manifest accepted") + } + if _, _, err := SignSubmissionAcknowledgement(definition, checkpoint, slot, envelope, envelopeRefs, manifest, ack, participantKey); err == nil { + t.Fatal("participant key accepted as coordinator acknowledgement signer") + } +} + +func TestSubmissionEnvelopeRejectsSiblingAllocationCheckpoint(t *testing.T) { + definition, checkpoint, slot, envelope, participantKey, _, _ := submissionFixture(t) + envelopeBytes, envelopeSignatureBytes, err := SignSubmissionEnvelope(definition, checkpoint, slot, envelope, participantKey) + if err != nil { + t.Fatal(err) + } + sibling := checkpoint + sibling.AcceptedArtifacts = checkpointArtifacts(append(sibling.AcceptedArtifacts, checkpointArtifact("sibling/marker.json", "different signed sibling"))...) + if err := sibling.Validate(); err != nil { + t.Fatal(err) + } + if _, err := VerifySignedSubmissionEnvelope(definition, sibling, slot, envelopeBytes, envelopeSignatureBytes); err == nil || !strings.Contains(err.Error(), "exact checkpoint") { + t.Fatalf("sibling checkpoint replay err=%v", err) + } +} + +func TestSubmissionEnvelopeRejectsWrongSlotRoleAndInventory(t *testing.T) { + definition, checkpoint, slot, valid, participantKey, _, _ := submissionFixture(t) + tests := []struct { + name string + mutate func(*SubmissionEnvelopeV1) + }{ + {"ceremony", func(v *SubmissionEnvelopeV1) { v.CeremonyID = "sha256:" + strings.Repeat("d", 64) }}, + {"definition", func(v *SubmissionEnvelopeV1) { v.Definition.Record.Name = "definition/other.json" }}, + {"attempt", func(v *SubmissionEnvelopeV1) { v.AttemptID = strings.Repeat("f", 32) }}, + {"manifest", func(v *SubmissionEnvelopeV1) { v.ManifestKey = "submissions/other/manifest.json" }}, + {"index", func(v *SubmissionEnvelopeV1) { v.Index = 2 }}, + {"parent checkpoint", func(v *SubmissionEnvelopeV1) { v.ParentCheckpointSHA256 = "sha256:" + strings.Repeat("f", 64) }}, + {"parent head", func(v *SubmissionEnvelopeV1) { v.ParentHeadID = "sha256:" + strings.Repeat("e", 64) }}, + {"release", func(v *SubmissionEnvelopeV1) { v.RelayReleaseID = "different-release" }}, + {"key", func(v *SubmissionEnvelopeV1) { v.SubmitterKeyID = definition.Roster[1].Identity.KeyID }}, + {"role", func(v *SubmissionEnvelopeV1) { v.SubmitterRole = "coordinator" }}, + {"unsorted inventory", func(v *SubmissionEnvelopeV1) { v.Payloads[0], v.Payloads[1] = v.Payloads[1], v.Payloads[0] }}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + changed := valid + changed.Payloads = append([]ArtifactRef(nil), valid.Payloads...) + test.mutate(&changed) + if _, _, err := SignSubmissionEnvelope(definition, checkpoint, slot, changed, participantKey); err == nil { + t.Fatal("changed binding accepted") + } + }) + } + if _, _, err := SignSubmissionEnvelope(definition, checkpoint, slot, valid, adversarialPrivateKey(0x12)); err == nil { + t.Fatal("wrong participant private key accepted") + } +} + +func TestRejectedSubmissionRequiresSafeReasonCode(t *testing.T) { + definition, checkpoint, slot, envelope, participantKey, coordinatorKey, manifestBytes := submissionFixture(t) + envelopeBytes, envelopeSignatureBytes, err := SignSubmissionEnvelope(definition, checkpoint, slot, envelope, participantKey) + if err != nil { + t.Fatal(err) + } + envelopeRefs := SignedArtifactRefs{ + Record: ArtifactRef{Name: "submissions/receipt/envelope.json", Digest: NewDigest(envelopeBytes)}, + Signature: ArtifactRef{Name: "submissions/receipt/envelope.sig", Digest: NewDigest(envelopeSignatureBytes)}, + } + manifest := ArtifactRef{Name: slot.ManifestKey, Digest: NewDigest(manifestBytes)} + base := SubmissionAcknowledgementV1{ + Schema: SubmissionAcknowledgementSchemaV1, Workflow: envelope.Workflow, CeremonyID: envelope.CeremonyID, + Definition: envelope.Definition, RelayReleaseID: envelope.RelayReleaseID, + CoordinatorID: definition.Coordinator.ID, CoordinatorKeyID: definition.Coordinator.KeyID, + SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, + Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, ParentHeadID: envelope.ParentHeadID, + AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Envelope: envelopeRefs, Manifest: manifest, Result: SubmissionRejected, ReasonCode: "invalid-signature", + } + if _, _, err := SignSubmissionAcknowledgement(definition, checkpoint, slot, envelope, envelopeRefs, manifest, base, coordinatorKey); err != nil { + t.Fatalf("safe rejection: %v", err) + } + base.ReasonCode = "secret: detailed operator message" + if _, _, err := SignSubmissionAcknowledgement(definition, checkpoint, slot, envelope, envelopeRefs, manifest, base, coordinatorKey); err == nil { + t.Fatal("unsafe rejection reason accepted") + } +} diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 2956f21d..1cdca52f 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -65,28 +65,41 @@ func main() { } func run(outputRoot, operationalEvidenceHelper string) error { - compiled, err := frontend.Compile( - ecc.BLS12_381.ScalarField(), - r1cs.NewBuilder, - &tinyCommittedCircuit{}, - ) - if err != nil { - return fmt.Errorf("compile tiny circuit: %w", err) - } - native, ok := compiled.(*cs.R1CS) - if !ok { - return fmt.Errorf("compiled circuit type %T, want *bls12-381.R1CS", compiled) + zeroAssurance := os.Getenv("PROOF_TOOL_TEST_ZERO_ASSURANCE") == "1" + checkpointPhase2One := os.Getenv("MPC_WORKFLOW_PHASE2_ONE") == "1" + var circuit *mpcceremony.CompiledCircuit + var err error + if checkpointPhase2One { + circuit, err = mpcceremony.CompileForKeyVersion(mpcceremony.KeyVersionRehearsal) + } else { + compiled, compileErr := frontend.Compile( + ecc.BLS12_381.ScalarField(), + r1cs.NewBuilder, + &tinyCommittedCircuit{}, + ) + if compileErr != nil { + return fmt.Errorf("compile tiny circuit: %w", compileErr) + } + native, ok := compiled.(*cs.R1CS) + if !ok { + return fmt.Errorf("compiled circuit type %T, want *bls12-381.R1CS", compiled) + } + circuit, err = mpcceremony.BindDestinationV2R1CS(native) } - circuit, err := mpcceremony.BindDestinationV2R1CS(native) if err != nil { - return fmt.Errorf("bind tiny circuit: %w", err) + return fmt.Errorf("compile tiny circuit: %w", err) } - software, err := mpcceremony.RunningSoftwareBindingForMode( - prover.ProofToolVersion, - mpcceremony.ModeRehearsal, - ) - if err != nil { - return fmt.Errorf("bind helper executable: %w", err) + var software mpcceremony.SoftwareBinding + if binary := os.Getenv("MPC_CEREMONY_TEST_BINARY"); binary != "" { + software, err = mpcceremony.SoftwareBindingFromExecutableFileForMode(binary, prover.ProofToolVersion, mpcceremony.ModeRehearsal) + if err != nil { + return fmt.Errorf("bind test command executable: %w", err) + } + } else { + software, err = mpcceremony.RunningSoftwareBindingForMode(prover.ProofToolVersion, mpcceremony.ModeRehearsal) + if err != nil { + return fmt.Errorf("bind helper executable: %w", err) + } } if err := os.Mkdir(outputRoot, 0o700); err != nil { @@ -162,15 +175,18 @@ func run(outputRoot, operationalEvidenceHelper string) error { if err != nil { return err } - auditor1KeyPath, err := writePrivateKey("auditor-01", auditor1Private) - if err != nil { - return err - } - auditor2KeyPath, err := writePrivateKey("auditor-02", auditor2Private) - if err != nil { - return err + auditor1KeyPath, auditor2KeyPath := "", "" + if !zeroAssurance { + auditor1KeyPath, err = writePrivateKey("auditor-01", auditor1Private) + if err != nil { + return err + } + auditor2KeyPath, err = writePrivateKey("auditor-02", auditor2Private) + if err != nil { + return err + } } - for _, external := range []struct { + externalKeys := []struct { name string fill byte }{ @@ -178,9 +194,12 @@ func run(outputRoot, operationalEvidenceHelper string) error { {name: "witness-02", fill: 0xa2}, {name: "mirror-01", fill: 0xb1}, {name: "mirror-02", fill: 0xb2}, - } { - if _, err := writePrivateKey(external.name, privateKey(external.fill)); err != nil { - return err + } + if !zeroAssurance { + for _, external := range externalKeys { + if _, err := writePrivateKey(external.name, privateKey(external.fill)); err != nil { + return err + } } } trustedCoordinatorPath := filepath.Join(keyDir, "trusted-coordinator.ed25519.public.hex") @@ -193,6 +212,22 @@ func run(outputRoot, operationalEvidenceHelper string) error { } ceremonyRoot := filepath.Join(outputRoot, "ceremony") + phaseMinimum := uint8(2) + phase2Minimum := uint8(2) + if os.Getenv("MPC_WORKFLOW_PHASE1_ONE") == "1" || checkpointPhase2One { + phaseMinimum = 1 + } + if checkpointPhase2One { + phase2Minimum = 1 + } + auditors := []mpcceremony.Identity{} + assurance := &mpcceremony.AssurancePolicy{} + if !zeroAssurance { + auditors = []mpcceremony.Identity{auditor1, auditor2} + assurance.PublicWitnessesPerPhase = 1 + assurance.MirrorsPerAcceptedHead = 1 + assurance.PassingCeremonyAudits = 1 + } initialized, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{ RootDir: ceremonyRoot, Circuit: circuit, @@ -203,18 +238,19 @@ func run(outputRoot, operationalEvidenceHelper string) error { Software: software, Coordinator: coordinator, ReleaseSigner: releaseSigner, - Auditors: []mpcceremony.Identity{auditor1, auditor2}, + Auditors: auditors, + AssurancePolicy: assurance, Roster: []mpcceremony.Participant{ {Identity: participant1}, {Identity: participant2}, }, Phase1Policy: mpcceremony.PhasePolicy{ Participants: []string{"participant-01", "participant-02"}, - Minimum: 2, + Minimum: phaseMinimum, }, Phase2Policy: mpcceremony.PhasePolicy{ Participants: []string{"participant-01", "participant-02"}, - Minimum: 2, + Minimum: phase2Minimum, }, BeaconPolicy: mpcceremony.BeaconPolicy{ Provider: mpcceremony.BeaconProviderDrand, @@ -316,6 +352,22 @@ func run(outputRoot, operationalEvidenceHelper string) error { EphemeralCleanupRequired: true, HostRemnantsNotExcluded: true, } + environmentPath := filepath.Join(outputRoot, "environment.json") + environmentBytes, err := mpcceremony.MarshalCanonical(environment) + if err != nil { + return err + } + if err := os.WriteFile(environmentPath, environmentBytes, 0o600); err != nil { + return err + } + testCommand := os.Getenv("MPC_CEREMONY_TEST_BINARY") + runTestCommand := func(args ...string) error { + output, commandErr := exec.Command(testCommand, args...).CombinedOutput() + if commandErr != nil { + return fmt.Errorf("run test mpc-ceremony %s: %w\n%s", strings.Join(args[:2], " "), commandErr, output) + } + return nil + } participantKeyPaths := []string{participant1KeyPath, participant2KeyPath} contributeAndAccept := func( phase mpcceremony.Phase, @@ -332,6 +384,67 @@ func run(outputRoot, operationalEvidenceHelper string) error { candidateRoot, fmt.Sprintf("%s-%s", phase, participantID), ) + if testCommand != "" { + command := []string{string(phase), "contribute", + "--ceremony", trust.DefinitionPath, + "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--transcript-dir", ceremonyRoot, + "--chain", chainPaths.ChainPath, + "--chain-signature", chainPaths.ChainSignaturePath, + "--participant-id", participantID, + "--participant-signing-key", participantKeyPaths[index-1], + "--environment", environmentPath, + "--contributed-at", contributedAt, + "--out-dir", candidateDir, + } + if phase == mpcceremony.Phase2 { + command = append(command, + "--phase1-seal", phase1SealPath, + "--phase1-seal-signature", phase1SealSignaturePath, + ) + } + if err := runTestCommand(command...); err != nil { + return mpcceremony.PhaseTranscriptPaths{}, err + } + if err := runTestCommand( + string(phase), "attest-erasure", + "--ceremony", trust.DefinitionPath, + "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--participant-id", participantID, + "--participant-signing-key", participantKeyPaths[index-1], + "--candidate-dir", candidateDir, + "--destroyed-at", destroyedAt, + ); err != nil { + return mpcceremony.PhaseTranscriptPaths{}, err + } + command = []string{string(phase), "verify", + "--ceremony", trust.DefinitionPath, + "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--transcript-dir", ceremonyRoot, + "--chain", chainPaths.ChainPath, + "--chain-signature", chainPaths.ChainSignaturePath, + "--candidate-dir", candidateDir, + "--coordinator-signing-key", coordinatorKeyPath, + "--accepted-at", acceptedAt, + } + if phase == mpcceremony.Phase2 { + command = append(command, + "--phase1-seal", phase1SealPath, + "--phase1-seal-signature", phase1SealSignaturePath, + ) + } + if err := runTestCommand(command...); err != nil { + return mpcceremony.PhaseTranscriptPaths{}, err + } + return mpcceremony.PhaseTranscriptPaths{ + RootDir: ceremonyRoot, + ChainPath: filepath.Join(ceremonyRoot, string(phase), fmt.Sprintf("chain-%04d.json", index)), + ChainSignaturePath: filepath.Join(ceremonyRoot, string(phase), fmt.Sprintf("chain-%04d.sig", index)), + }, nil + } if _, err := mpcceremony.CreateContributionCandidate( mpcceremony.ContributionFilesOptions{ Trust: trust, @@ -401,18 +514,21 @@ func run(outputRoot, operationalEvidenceHelper string) error { if err != nil { return fmt.Errorf("Phase 1 participant 1: %w", err) } - phase1Paths, err = contributeAndAccept( - mpcceremony.Phase1, - 2, - phase1Paths, - "", - "", - "2023-08-23T15:03:00Z", - "2023-08-23T15:03:01Z", - "2023-08-23T15:04:00Z", - ) - if err != nil { - return fmt.Errorf("Phase 1 participant 2: %w", err) + checkpointPhase1One := os.Getenv("MPC_WORKFLOW_PHASE1_ONE") == "1" + if !checkpointPhase1One && !checkpointPhase2One { + phase1Paths, err = contributeAndAccept( + mpcceremony.Phase1, + 2, + phase1Paths, + "", + "", + "2023-08-23T15:03:00Z", + "2023-08-23T15:03:01Z", + "2023-08-23T15:04:00Z", + ) + if err != nil { + return fmt.Errorf("Phase 1 participant 2: %w", err) + } } phase1Chain, err := mpcceremony.LoadReplayPhase1Files(trusted, circuit, phase1Paths) if err != nil { @@ -434,71 +550,146 @@ func run(outputRoot, operationalEvidenceHelper string) error { if err := os.WriteFile(round42Path, []byte(quicknetRound42), 0o600); err != nil { return err } - phase1Beacon, err := mpcceremony.RecordBeaconFiles(mpcceremony.RecordBeaconFilesOptions{ - Trust: trust, - TranscriptRoot: ceremonyRoot, - Phase: mpcceremony.Phase1, - ClosePath: phase1Close.ClosePath, - CloseSignaturePath: phase1Close.SignaturePath, - RawResponsePath: round42Path, - PublishedAt: "2023-08-23T15:11:30Z", - CoordinatorPrivateKeyPath: coordinatorKeyPath, - }) - if err != nil { - return fmt.Errorf("record Phase 1 beacon: %w", err) + var phase1Seal mpcceremony.SealPhase1FilesResult + var phase1Beacon mpcceremony.RecordBeaconFilesResult + if (checkpointPhase1One || checkpointPhase2One) && testCommand != "" { + if err := runTestCommand("phase1", "beacon", + "--ceremony", trust.DefinitionPath, + "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--closure", phase1Close.ClosePath, + "--closure-signature", phase1Close.SignaturePath, + "--raw-response", round42Path, + "--published-at", "2023-08-23T15:11:30Z", + "--coordinator-signing-key", coordinatorKeyPath, + "--transcript-dir", ceremonyRoot, + ); err != nil { + return fmt.Errorf("record checkpoint Phase 1 beacon: %w", err) + } + if err := runTestCommand("phase1", "seal", + "--ceremony", trust.DefinitionPath, + "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--transcript-dir", ceremonyRoot, + "--closure", phase1Close.ClosePath, + "--closure-signature", phase1Close.SignaturePath, + "--beacon", filepath.Join(ceremonyRoot, "phase1", "beacon", "record.json"), + "--beacon-signature", filepath.Join(ceremonyRoot, "phase1", "beacon", "record.sig"), + "--coordinator-signing-key", coordinatorKeyPath, + "--out-dir", filepath.Join(ceremonyRoot, "phase1", "sealed"), + ); err != nil { + return fmt.Errorf("seal checkpoint Phase 1: %w", err) + } + if checkpointPhase1One { + return nil + } + sealPath := filepath.Join(ceremonyRoot, "phase1", "sealed", "seal.json") + sealSignaturePath := filepath.Join(ceremonyRoot, "phase1", "sealed", "seal.sig") + sealBytes, readErr := os.ReadFile(sealPath) + if readErr != nil { + return readErr + } + sealSignatureBytes, readErr := os.ReadFile(sealSignaturePath) + if readErr != nil { + return readErr + } + var seal mpcceremony.SealRecord + if verifyErr := mpcceremony.VerifySignedRecord(sealBytes, sealSignatureBytes, &seal, coordinator.KeyID, coordinatorPrivate.Public().(ed25519.PublicKey)); verifyErr != nil { + return fmt.Errorf("verify checkpoint Phase 1 seal record: %w", verifyErr) + } + if len(seal.Outputs) != 1 { + return fmt.Errorf("checkpoint Phase 1 seal has %d outputs", len(seal.Outputs)) + } + phase1Seal = mpcceremony.SealPhase1FilesResult{ + Seal: seal, CommonsPath: filepath.Join(ceremonyRoot, filepath.FromSlash(seal.Outputs[0].Name)), + SealPath: sealPath, SignaturePath: sealSignaturePath, + } } - // 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, - TranscriptRoot: ceremonyRoot, - ClosePath: phase1Close.ClosePath, - CloseSignaturePath: phase1Close.SignaturePath, - BeaconPath: phase1Beacon.BeaconPath, - 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 !checkpointPhase2One { + phase1Beacon, err = mpcceremony.RecordBeaconFiles(mpcceremony.RecordBeaconFilesOptions{ + Trust: trust, + TranscriptRoot: ceremonyRoot, + Phase: mpcceremony.Phase1, + ClosePath: phase1Close.ClosePath, + CloseSignaturePath: phase1Close.SignaturePath, + RawResponsePath: round42Path, + PublishedAt: "2023-08-23T15:11:30Z", + CoordinatorPrivateKeyPath: coordinatorKeyPath, + }) + 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, + TranscriptRoot: ceremonyRoot, + ClosePath: phase1Close.ClosePath, + CloseSignaturePath: phase1Close.SignaturePath, + BeaconPath: phase1Beacon.BeaconPath, + 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") + } } - if sealProgress == 0 { - return errors.New("Phase 1 seal replayed without reporting progress") + // The checkpoint fixture needs one authentic turn through the deterministic + // historical Phase 1 seal, but not the later Phase 2 and release fixtures. + if checkpointPhase1One { + return nil } // 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, - TranscriptRoot: ceremonyRoot, - Phase1SealPath: phase1Seal.SealPath, - Phase1SealSignaturePath: phase1Seal.SignaturePath, - CoordinatorPrivateKeyPath: coordinatorKeyPath, - 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) + var phase2Initialized mpcceremony.InitPhase2FilesResult + if checkpointPhase2One && testCommand != "" { + if err := runTestCommand("phase2", "init", + "--ceremony", trust.DefinitionPath, "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--phase1-transcript-dir", ceremonyRoot, "--phase1-seal", phase1Seal.SealPath, + "--phase1-seal-signature", phase1Seal.SignaturePath, + "--coordinator-signing-key", coordinatorKeyPath, "--out-dir", filepath.Join(ceremonyRoot, "phase2")); err != nil { + return fmt.Errorf("initialize checkpoint Phase 2: %w", err) + } + phase2Initialized = mpcceremony.InitPhase2FilesResult{ + GenesisPath: filepath.Join(ceremonyRoot, "phase2", "genesis.bin"), + ChainPath: filepath.Join(ceremonyRoot, "phase2", "chain-0000.json"), + ChainSignaturePath: filepath.Join(ceremonyRoot, "phase2", "chain-0000.sig"), + } + } else { + phase2Initialized, err = mpcceremony.InitializePhase2Files(mpcceremony.InitPhase2FilesOptions{ + Trust: trust, Circuit: circuit, TranscriptRoot: ceremonyRoot, + Phase1SealPath: phase1Seal.SealPath, Phase1SealSignaturePath: phase1Seal.SignaturePath, + CoordinatorPrivateKeyPath: coordinatorKeyPath, + 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, @@ -518,18 +709,138 @@ func run(outputRoot, operationalEvidenceHelper string) error { if err != nil { return fmt.Errorf("Phase 2 participant 1: %w", err) } - phase2Paths, err = contributeAndAccept( - mpcceremony.Phase2, - 2, - phase2Paths, - phase1Seal.SealPath, - phase1Seal.SignaturePath, - "2023-08-23T15:11:30.4Z", - "2023-08-23T15:11:30.5Z", - "2023-08-23T15:11:30.6Z", - ) - if err != nil { - return fmt.Errorf("Phase 2 participant 2: %w", err) + if !checkpointPhase2One { + phase2Paths, err = contributeAndAccept( + mpcceremony.Phase2, + 2, + phase2Paths, + phase1Seal.SealPath, + phase1Seal.SignaturePath, + "2023-08-23T15:11:30.4Z", + "2023-08-23T15:11:30.5Z", + "2023-08-23T15:11:30.6Z", + ) + if err != nil { + return fmt.Errorf("Phase 2 participant 2: %w", err) + } + } + if checkpointPhase2One { + commons, _, err := mpcceremony.ReadCommonsFile( + phase1Seal.CommonsPath, + mpcceremony.CommonsShape{DomainN: circuit.Binding.DomainSize}, + ) + if err != nil { + return fmt.Errorf("read sealed commons for checkpoint Phase 2 closure: %w", err) + } + phase2Chain, err := mpcceremony.LoadReplayPhase2Files(trusted, circuit, commons, phase1Seal.Seal, phase2Paths) + if err != nil { + return fmt.Errorf("replay checkpoint Phase 2 before closure: %w", err) + } + phase2Close, err := writeHistoricalClose(mpcceremony.Phase2, phase2Chain, 43, "2023-08-23T15:11:30.7Z") + if err != nil { + return fmt.Errorf("close checkpoint Phase 2: %w", err) + } + round43Path := filepath.Join(outputRoot, "quicknet-round-43.json") + if err := os.WriteFile(round43Path, []byte(quicknetRound43), 0o600); err != nil { + return err + } + if err := runTestCommand("phase2", "beacon", + "--ceremony", trust.DefinitionPath, + "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--closure", phase2Close.ClosePath, + "--closure-signature", phase2Close.SignaturePath, + "--raw-response", round43Path, + "--published-at", "2023-08-23T15:11:33Z", + "--coordinator-signing-key", coordinatorKeyPath, + "--transcript-dir", ceremonyRoot, + ); err != nil { + return fmt.Errorf("record checkpoint Phase 2 beacon: %w", err) + } + replayArgs := []string{ + "--transcript-root", ceremonyRoot, + "--phase1-chain", phase1Paths.ChainPath, "--phase1-chain-signature", phase1Paths.ChainSignaturePath, + "--phase1-close", phase1Close.ClosePath, "--phase1-close-signature", phase1Close.SignaturePath, + "--phase1-beacon", filepath.Join(ceremonyRoot, "phase1", "beacon", "record.json"), + "--phase1-beacon-signature", filepath.Join(ceremonyRoot, "phase1", "beacon", "record.sig"), + "--phase1-seal", phase1Seal.SealPath, "--phase1-seal-signature", phase1Seal.SignaturePath, + "--phase2-chain", phase2Paths.ChainPath, "--phase2-chain-signature", phase2Paths.ChainSignaturePath, + "--phase2-close", phase2Close.ClosePath, "--phase2-close-signature", phase2Close.SignaturePath, + "--phase2-beacon", filepath.Join(ceremonyRoot, "phase2", "beacon", "record.json"), + "--phase2-beacon-signature", filepath.Join(ceremonyRoot, "phase2", "beacon", "record.sig"), + } + preliminaryDir := filepath.Join(outputRoot, "checkpoint-preliminary") + prepareArgs := []string{"finalize", "prepare", + "--ceremony", trust.DefinitionPath, "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + } + prepareArgs = append(prepareArgs, replayArgs...) + prepareArgs = append(prepareArgs, "--coordinator-signing-key", coordinatorKeyPath, "--prepared-at", "2023-08-23T15:11:34Z", "--out-dir", preliminaryDir) + if err := runTestCommand(prepareArgs...); err != nil { + return fmt.Errorf("prepare checkpoint finalization: %w", err) + } + publicEvidencePath := filepath.Join(outputRoot, "checkpoint-public-finalization-evidence.json") + if err := runTestCommand("finalize", "rehearsal-evidence", + "--keys-dir", preliminaryDir, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + "--ceremony-id", trusted.Definition.CeremonyID, + "--out", publicEvidencePath, + ); err != nil { + return fmt.Errorf("create checkpoint rehearsal evidence: %w", err) + } + candidateDir := filepath.Join(ceremonyRoot, "final", "candidate") + if err := os.Mkdir(filepath.Dir(candidateDir), 0o700); err != nil { + return fmt.Errorf("create checkpoint final directory: %w", err) + } + completeArgs := []string{"finalize", "complete", + "--ceremony", trust.DefinitionPath, "--ceremony-signature", trust.DefinitionSignaturePath, + "--coordinator-public-key-file", trust.CoordinatorPublicKeyPath, + } + completeArgs = append(completeArgs, replayArgs...) + completeArgs = append(completeArgs, + "--coordinator-signing-key", coordinatorKeyPath, + "--public-evidence", publicEvidencePath, + "--finalized-at", "2023-08-23T15:11:35Z", + "--out-dir", candidateDir, + ) + if err := runTestCommand(completeArgs...); err != nil { + return fmt.Errorf("complete checkpoint finalization: %w", err) + } + if operationalEvidenceHelper != "" { + phase1Relays, err := writeRelayFixture(outputRoot, "checkpoint-phase1-relays", []byte(quicknetRound42), "2023-08-23T15:11:30Z") + if err != nil { + return err + } + phase2Relays, err := writeRelayFixture(outputRoot, "checkpoint-phase2-relays", []byte(quicknetRound43), "2023-08-23T15:11:33Z") + if err != nil { + return err + } + operationalCommand := exec.Command(operationalEvidenceHelper, + "--transcript-root", ceremonyRoot, "--keys-dir", keyDir, + "--coordinator-public-key-file", trustedCoordinatorPath, + "--phase1-relays", phase1Relays, "--phase2-relays", phase2Relays, + "--assembled-at", "2023-08-23T15:11:36Z", "--out-dir", filepath.Join(ceremonyRoot, "operational")) + if output, err := operationalCommand.CombinedOutput(); err != nil { + return fmt.Errorf("generate checkpoint operational evidence: %w\n%s", err, output) + } + releaseDir := filepath.Join(ceremonyRoot, "final", "release") + releaseArgs := []string{"release", "sign", + "--ceremony", initialized.DefinitionPath, "--ceremony-signature", initialized.DefinitionSignaturePath, + "--coordinator-public-key-file", trustedCoordinatorPath, + } + releaseArgs = append(releaseArgs, replayArgs...) + releaseArgs = append(releaseArgs, + "--candidate-bundle", candidateDir, "--operational-evidence-root", ceremonyRoot, + "--operational-bundle", filepath.Join(ceremonyRoot, mpcceremony.OperationalEvidenceBundleFile), + "--operational-bundle-signature", filepath.Join(ceremonyRoot, mpcceremony.OperationalEvidenceSignatureFile), + "--release-signing-key", releaseKeyPath, "--signature-key-id", releaseSigner.KeyID, + "--released-at", "2023-08-23T15:11:38Z", "--release-dir", releaseDir, + ) + if err := runTestCommand(releaseArgs...); err != nil { + return fmt.Errorf("sign checkpoint release: %w", err) + } + } + return nil } _, err = mpcceremony.ClosePhaseFiles(mpcceremony.ClosePhaseFilesOptions{ Trust: trust, @@ -717,18 +1028,24 @@ func run(outputRoot, operationalEvidenceHelper string) error { } auditDir := filepath.Join(outputRoot, "audits") - if err := os.Mkdir(auditDir, 0o700); err != nil { - return err - } audits := make([]mpcceremony.AuditArtifact, 0, 2) - for index, input := range []struct { + auditInputs := []struct { id string keyPath string at string }{ {id: auditor1.ID, keyPath: auditor1KeyPath, at: "2023-08-23T15:11:36Z"}, {id: auditor2.ID, keyPath: auditor2KeyPath, at: "2023-08-23T15:11:37Z"}, - } { + } + if zeroAssurance { + auditInputs = nil + } + if len(auditInputs) > 0 { + if err := os.Mkdir(auditDir, 0o700); err != nil { + return err + } + } + for index, input := range auditInputs { recordPath := filepath.Join(auditDir, fmt.Sprintf("audit-%02d.json", index+1)) signaturePath := filepath.Join(auditDir, fmt.Sprintf("audit-%02d.sig", index+1)) if _, err := mpcceremony.Audit(mpcceremony.AuditOptions{ @@ -783,6 +1100,9 @@ func run(outputRoot, operationalEvidenceHelper string) error { } releaseDir := filepath.Join(outputRoot, "release") + if checkpointPhase2One { + releaseDir = filepath.Join(ceremonyRoot, "final", "release") + } if _, err := mpcceremony.SignRelease(mpcceremony.SignReleaseOptions{ DefinitionPath: initialized.DefinitionPath, DefinitionSignaturePath: initialized.DefinitionSignaturePath, @@ -796,6 +1116,8 @@ func run(outputRoot, operationalEvidenceHelper string) error { ReleaseSigningKey: releaseKeyPath, SignatureKeyID: releaseSigner.KeyID, ReleasedAt: mustUTC("2023-08-23T15:11:38Z"), + Replay: &replay, + Circuit: circuit, }); err != nil { return fmt.Errorf("sign release: %w", err) } diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 9c0b4bbf..5772484f 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -73,9 +73,6 @@ func (p InitParticipants) Validate() error { if err := p.ReleaseSigner.Validate(); err != nil { return fmt.Errorf("release_signer: %w", err) } - if len(p.Auditors) < 1 { - return errors.New("at least one independent auditor is required") - } if len(p.Auditors) > MaxAuditors { return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) } @@ -130,11 +127,28 @@ func (p InitParticipants) Validate() error { // Cross-checking policy participant IDs against InitParticipants happens when // the ceremony definition is assembled and validated. type InitPolicy struct { + Phase1Policy PhasePolicy `json:"phase1_policy"` + Phase2Policy PhasePolicy `json:"phase2_policy"` + BeaconPolicy BeaconPolicy `json:"beacon_policy"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy"` + legacy bool +} + +type legacyInitPolicy struct { Phase1Policy PhasePolicy `json:"phase1_policy"` Phase2Policy PhasePolicy `json:"phase2_policy"` BeaconPolicy BeaconPolicy `json:"beacon_policy"` } +func (p legacyInitPolicy) Validate() error { + return InitPolicy{ + Phase1Policy: p.Phase1Policy, + Phase2Policy: p.Phase2Policy, + BeaconPolicy: p.BeaconPolicy, + legacy: true, + }.Validate() +} + func (p InitPolicy) Validate() error { if err := validateUnboundPhasePolicy(p.Phase1Policy); err != nil { return fmt.Errorf("phase1_policy: %w", err) @@ -145,9 +159,26 @@ func (p InitPolicy) Validate() error { if err := p.BeaconPolicy.Validate(); err != nil { return fmt.Errorf("beacon_policy: %w", err) } + if p.AssurancePolicy == nil && !p.legacy { + return errors.New("assurance_policy is required; omission does not disable controls") + } return nil } +// ResolvedAssurancePolicy maps a policy file created before assurance controls +// existed to the old mandatory defaults. Only LoadInitPolicy can mark a value +// as legacy; a newly authored omission remains an error. +func (p InitPolicy) ResolvedAssurancePolicy(mode string) (*AssurancePolicy, error) { + if err := p.Validate(); err != nil { + return nil, err + } + if p.AssurancePolicy != nil { + return cloneAssurancePolicy(p.AssurancePolicy), nil + } + value := defaultAssurancePolicy(mode) + return &value, nil +} + // LoadInitParticipants reads exact canonical enrollment JSON from a regular // file. Unknown/duplicate fields, trailing bytes, and non-canonical encodings // are rejected. @@ -161,10 +192,27 @@ func LoadInitParticipants(path string) (InitParticipants, error) { // LoadInitPolicy reads exact canonical initialization policy JSON. func LoadInitPolicy(path string) (InitPolicy, error) { - var result InitPolicy - if err := loadCanonicalInput(path, &result); err != nil { + data, err := readRegularBounded(path, maxSignedRecordBytes) + if err != nil { return InitPolicy{}, fmt.Errorf("load init policy: %w", err) } + var result InitPolicy + if err := UnmarshalCanonical(data, &result); err == nil { + if err := result.Validate(); err != nil { + return InitPolicy{}, fmt.Errorf("load init policy: %w", err) + } + return result, nil + } + var legacy legacyInitPolicy + if err := UnmarshalCanonical(data, &legacy); err != nil { + return InitPolicy{}, fmt.Errorf("load init policy: neither current nor legacy canonical policy: %w", err) + } + result = InitPolicy{ + Phase1Policy: legacy.Phase1Policy, + Phase2Policy: legacy.Phase2Policy, + BeaconPolicy: legacy.BeaconPolicy, + legacy: true, + } return result, nil } @@ -512,6 +560,54 @@ func LoadSignedChainExact(trusted *TrustedCeremony, paths PhaseTranscriptPaths) return chain, refs, nil } +// VerifyAcceptedPhase1Chain replays every Phase 1 transition and returns only +// after the signed chain, contribution mathematics, participant attestations, +// cleanup acknowledgements, and coordinator verification records agree. It is +// read-only and is the checkpoint verifier's cp3 boundary. +func VerifyAcceptedPhase1Chain(trust TrustPaths, circuit *CompiledCircuit, paths PhaseTranscriptPaths) (Chain, SignedArtifactRefs, error) { + trusted, err := loadOperationalCeremony(trust) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + if err := validateWorkflowCircuit(trusted, circuit); err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + chain, err := loadVerifiedPhase1Files(trusted, circuit, paths) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + _, refs, err := LoadSignedChainExact(trusted, paths) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + return chain, refs, nil +} + +// VerifyAcceptedPhase2Chain fully replays sealed Phase 1 and every accepted +// Phase 2 transition, returning references to the exact authenticated chain. +func VerifyAcceptedPhase2Chain(trust TrustPaths, circuit *CompiledCircuit, transcriptRoot, phase1SealPath, phase1SealSignaturePath string, paths PhaseTranscriptPaths) (Chain, SignedArtifactRefs, error) { + trusted, err := loadOperationalCeremony(trust) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + if err := validateWorkflowCircuit(trusted, circuit); err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + commons, seal, _, err := loadPhase1CommonsForPhase2(trusted, circuit, transcriptRoot, phase1SealPath, phase1SealSignaturePath) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + chain, refs, err := loadVerifiedPhase2FilesExact(trusted, circuit, commons, seal, paths) + if err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + loader := phase2FileLoader(paths.RootDir, chain, contributionPhase2Shape(circuit.Binding.Phase2Shape), paths.Progress) + if err := ReplayPhase2Loaded(circuit, commons, len(chain.Records), loader); err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + return chain, refs, nil +} + // LoadReplayPhase1Files strictly reads all accepted evidence and replays every // native Phase 1 transition while retaining at most the states needed by gnark. func loadVerifiedPhase1Files( @@ -585,21 +681,32 @@ func loadVerifiedPhase2Files( phase1Seal SealRecord, paths PhaseTranscriptPaths, ) (Chain, error) { + chain, _, err := loadVerifiedPhase2FilesExact(trusted, circuit, commons, phase1Seal, paths) + return chain, err +} + +func loadVerifiedPhase2FilesExact( + trusted *TrustedCeremony, + circuit *CompiledCircuit, + commons *gnarkmpc.SrsCommons, + phase1Seal SealRecord, + paths PhaseTranscriptPaths, +) (Chain, SignedArtifactRefs, error) { if err := validateWorkflowCircuit(trusted, circuit); err != nil { - return Chain{}, err + return Chain{}, SignedArtifactRefs{}, err } if commons == nil { - return Chain{}, errors.New("sealed Phase 1 commons are required") + return Chain{}, SignedArtifactRefs{}, errors.New("sealed Phase 1 commons are required") } - chain, err := LoadSignedChain(trusted, paths) + chain, refs, err := LoadSignedChainExact(trusted, paths) if err != nil { - return Chain{}, err + return Chain{}, SignedArtifactRefs{}, err } if chain.Phase != Phase2 { - return Chain{}, fmt.Errorf("chain phase is %q, want phase2", chain.Phase) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("chain phase is %q, want phase2", chain.Phase) } if phase1Seal.CeremonyID != trusted.Definition.CeremonyID || phase1Seal.Phase != Phase1 { - return Chain{}, errors.New("Phase 2 chain requires the signed Phase 1 seal") + return Chain{}, SignedArtifactRefs{}, errors.New("Phase 2 chain requires the signed Phase 1 seal") } expectedPhaseID, err := ComputePhaseID( trusted.Definition.CeremonyID, @@ -608,21 +715,21 @@ func loadVerifiedPhase2Files( phase1Seal.SealID, ) if err != nil { - return Chain{}, err + return Chain{}, SignedArtifactRefs{}, err } if chain.PhaseID != expectedPhaseID { - return Chain{}, errors.New("Phase 2 chain ID does not bind the signed Phase 1 seal") + return Chain{}, SignedArtifactRefs{}, errors.New("Phase 2 chain ID does not bind the signed Phase 1 seal") } deterministicGenesis, deterministicShape, err := InitializePhase2(circuit, commons) if err != nil { - return Chain{}, fmt.Errorf("recompute deterministic Phase 2 genesis: %w", err) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("recompute deterministic Phase 2 genesis: %w", err) } if !equalPhase2Shape(deterministicShape, circuit.Binding.Phase2Shape) { - return Chain{}, errors.New("deterministic Phase 2 genesis shape differs from signed circuit binding") + return Chain{}, SignedArtifactRefs{}, errors.New("deterministic Phase 2 genesis shape differs from signed circuit binding") } expectedGenesisSize, err := ExpectedPhase2Size(deterministicShape) if err != nil { - return Chain{}, err + return Chain{}, SignedArtifactRefs{}, err } genesisHash := newDualHash() written, err := writeToWithPanicBoundary( @@ -631,18 +738,18 @@ func loadVerifiedPhase2Files( genesisHash, ) if err != nil { - return Chain{}, fmt.Errorf("hash deterministic Phase 2 genesis: %w", err) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("hash deterministic Phase 2 genesis: %w", err) } if written != expectedGenesisSize { - return Chain{}, fmt.Errorf("deterministic Phase 2 genesis wrote %d bytes, expected %d", written, expectedGenesisSize) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("deterministic Phase 2 genesis wrote %d bytes, expected %d", written, expectedGenesisSize) } if modelDigest(genesisHash.digest(written, nil)) != chain.Genesis.Digest { - return Chain{}, errors.New("Phase 2 chain genesis is not the deterministic circuit/commons initialization") + return Chain{}, SignedArtifactRefs{}, errors.New("Phase 2 chain genesis is not the deterministic circuit/commons initialization") } if err := verifyChainFiles(trusted, paths.RootDir, chain, circuit.Binding.Phase2Shape); err != nil { - return Chain{}, err + return Chain{}, SignedArtifactRefs{}, err } - return chain, nil + return chain, refs, nil } func LoadReplayPhase2Files( @@ -652,7 +759,7 @@ func LoadReplayPhase2Files( phase1Seal SealRecord, paths PhaseTranscriptPaths, ) (Chain, error) { - chain, err := loadVerifiedPhase2Files(trusted, circuit, commons, phase1Seal, paths) + chain, _, err := loadVerifiedPhase2FilesExact(trusted, circuit, commons, phase1Seal, paths) if err != nil { return Chain{}, err } @@ -1942,6 +2049,147 @@ type SealPhase1FilesResult struct { SignaturePath string } +type VerifyPhase1SealFilesOptions struct { + Trust TrustPaths + Circuit *CompiledCircuit + TranscriptRoot string + Phase1ChainPath string + Phase1ChainSignaturePath string + Phase1ClosePath string + Phase1CloseSignaturePath string + Phase1BeaconPath string + Phase1BeaconSignaturePath string + Phase1SealPath string + Phase1SealSignaturePath string +} + +type VerifyPhase1SealFilesResult struct { + Seal SealRecord + Close CloseRecord + Commons ArtifactRef +} + +type VerifyPhase2GenesisFilesOptions struct { + Trust TrustPaths + Circuit *CompiledCircuit + TranscriptRoot string + Phase1SealPath string + Phase1SealSignaturePath string + Phase2ChainPath string + Phase2ChainSignaturePath string +} + +type VerifyPhase2GenesisFilesResult struct { + Chain Chain + ChainRefs SignedArtifactRefs + Genesis ArtifactRef +} + +// VerifyPhase2GenesisFiles fully replays sealed Phase 1 and proves that the +// signed zero-contribution Phase 2 chain names its deterministic genesis. +func VerifyPhase2GenesisFiles(options VerifyPhase2GenesisFilesOptions) (VerifyPhase2GenesisFilesResult, error) { + trusted, err := loadOperationalCeremony(options.Trust) + if err != nil { + return VerifyPhase2GenesisFilesResult{}, err + } + if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil { + return VerifyPhase2GenesisFilesResult{}, err + } + commons, seal, _, err := loadPhase1CommonsForPhase2(trusted, options.Circuit, options.TranscriptRoot, options.Phase1SealPath, options.Phase1SealSignaturePath) + if err != nil { + return VerifyPhase2GenesisFilesResult{}, fmt.Errorf("verify sealed Phase 1: %w", err) + } + paths := PhaseTranscriptPaths{RootDir: options.TranscriptRoot, ChainPath: options.Phase2ChainPath, ChainSignaturePath: options.Phase2ChainSignaturePath} + verified, refs, err := loadVerifiedPhase2FilesExact(trusted, options.Circuit, commons, seal, paths) + if err != nil { + return VerifyPhase2GenesisFilesResult{}, err + } + if len(verified.Records) != 0 { + return VerifyPhase2GenesisFilesResult{}, errors.New("Phase 2 initialization requires a zero-contribution chain") + } + loader := phase2FileLoader(paths.RootDir, verified, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape), paths.Progress) + if err := ReplayPhase2Loaded(options.Circuit, commons, len(verified.Records), loader); err != nil { + return VerifyPhase2GenesisFilesResult{}, err + } + genesis, err := verified.HeadPayload() + if err != nil { + return VerifyPhase2GenesisFilesResult{}, err + } + return VerifyPhase2GenesisFilesResult{Chain: verified, ChainRefs: refs, Genesis: genesis}, nil +} + +// VerifyPhase1SealFiles performs the same full, read-only Phase 1 replay used +// before Phase 2. It proves that the signed seal's commons file was derived +// from the authenticated accepted chain and recorded beacon; it never signs or +// writes ceremony state. +func VerifyPhase1SealFiles(options VerifyPhase1SealFilesOptions) (VerifyPhase1SealFilesResult, error) { + trusted, err := loadOperationalCeremony(options.Trust) + if err != nil { + return VerifyPhase1SealFilesResult{}, err + } + if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil { + return VerifyPhase1SealFilesResult{}, err + } + var seal SealRecord + if err := loadCoordinatorSignedRecord(trusted, options.Phase1SealPath, options.Phase1SealSignaturePath, &seal); err != nil { + return VerifyPhase1SealFilesResult{}, err + } + if seal.CeremonyID != trusted.Definition.CeremonyID || seal.Phase != Phase1 { + return VerifyPhase1SealFilesResult{}, errors.New("Phase 1 seal ceremony or phase mismatch") + } + var closeRecord CloseRecord + if err := loadCoordinatorSignedRecord(trusted, options.Phase1ClosePath, options.Phase1CloseSignaturePath, &closeRecord); err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("load exact Phase 1 closure: %w", err) + } + chain, replayedHead, err := loadReplayPhase1FilesState(trusted, options.Circuit, PhaseTranscriptPaths{ + RootDir: options.TranscriptRoot, ChainPath: options.Phase1ChainPath, ChainSignaturePath: options.Phase1ChainSignaturePath, + }) + if err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("replay exact closed Phase 1 chain: %w", err) + } + if err := ValidateClose(trusted.Definition, chain, closeRecord); err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("validate exact Phase 1 closure: %w", err) + } + var beacon BeaconRecord + if err := loadCoordinatorSignedRecord(trusted, options.Phase1BeaconPath, options.Phase1BeaconSignaturePath, &beacon); err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("load exact Phase 1 beacon: %w", err) + } + if err := VerifyBeaconRecordFiles(trusted, options.TranscriptRoot, closeRecord, beacon); err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("verify exact Phase 1 beacon: %w", err) + } + if err := ValidateSeal(closeRecord, beacon, seal); err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("validate Phase 1 seal: %w", err) + } + challenge, err := hex.DecodeString(beacon.ChallengeHex) + if err != nil || len(challenge) != contributionChallengeSize { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("Phase 1 beacon challenge must be exactly %d bytes", contributionChallengeSize) + } + derivedCommons, err := sealReplayedPhase1Head(options.Circuit.Binding.DomainSize, challenge, replayedHead) + if err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("derive Phase 1 commons: %w", err) + } + derivedDigest, err := writerDigest(derivedCommons) + if err != nil { + return VerifyPhase1SealFilesResult{}, fmt.Errorf("digest derived Phase 1 commons: %w", err) + } + commons, err := phase1CommonsOutput(seal) + if err != nil { + return VerifyPhase1SealFilesResult{}, err + } + commonsPath, err := resolveArtifactPath(options.TranscriptRoot, commons.Name) + if err != nil { + return VerifyPhase1SealFilesResult{}, err + } + _, storedDigest, err := ReadCommonsFile(commonsPath, CommonsShape{DomainN: options.Circuit.Binding.DomainSize}) + if err != nil { + return VerifyPhase1SealFilesResult{}, err + } + if modelDigest(storedDigest) != commons.Digest || derivedDigest != commons.Digest { + return VerifyPhase1SealFilesResult{}, errors.New("Phase 1 commons do not match the signed seal and authenticated derivation") + } + return VerifyPhase1SealFilesResult{Seal: seal, Close: closeRecord, Commons: commons}, nil +} + // SealPhase1Files verifies the signed closure and future beacon, replays Phase // 1 from immutable files, and publishes native commons plus a signed seal. func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResult, err error) { @@ -3165,6 +3413,9 @@ func loadAuthenticatedPhase1CommonsForCoordinator( } func phase1CommonsOutput(seal SealRecord) (ArtifactRef, error) { + if seal.Phase != Phase1 || len(seal.Outputs) != 1 { + return ArtifactRef{}, errors.New("Phase 1 seal must contain exactly one commons output") + } var commonsRef *ArtifactRef for i := range seal.Outputs { if strings.HasSuffix(seal.Outputs[i].Name, "/commons.bin") || diff --git a/internal/mpcrehearsal/config.go b/internal/mpcrehearsal/config.go index 2549696d..19f4b337 100644 --- a/internal/mpcrehearsal/config.go +++ b/internal/mpcrehearsal/config.go @@ -22,8 +22,8 @@ const ( maxRehearsalParticipants = 20 // MinimumBeaconLeadSeconds gives automated rehearsals four Quicknet // periods to commit to a round that does not exist yet. Rehearsal outputs - // are explicitly non-production; production policy has its own 24-hour - // minimum in mpcceremony. + // are explicitly non-production. Production tooling recommends 24 hours, + // but the exact signed ceremony policy is configurable. MinimumBeaconLeadSeconds = 12 ) @@ -33,6 +33,13 @@ type generatedIdentity struct { } func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (err error) { + return GenerateWithAssurance(outDir, participantCount, beaconWitnessLead, true) +} + +// GenerateWithAssurance exposes both supported rehearsal paths to the real +// command surface. When optionalAssurance is false, the signed policy uses +// explicit zeroes and no auditor is enrolled; drand verification remains on. +func GenerateWithAssurance(outDir string, participantCount int, beaconWitnessLead uint32, optionalAssurance bool) (err error) { if participantCount < minRehearsalParticipants || participantCount > maxRehearsalParticipants { return fmt.Errorf( @@ -89,39 +96,32 @@ func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (er 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, + } + var auditor1, auditor2 generatedIdentity + if optionalAssurance { + for _, spec := range []struct{ id, display string }{ + {"auditor-01", "Local Rehearsal Auditor 01"}, + {"auditor-02", "Local Rehearsal Auditor 02"}, + {"witness-01", "Local Rehearsal Public Witness 01"}, + {"witness-02", "Local Rehearsal Public Witness 02"}, + {"mirror-01", "Local Rehearsal Mirror Operator 01"}, + {"mirror-02", "Local Rehearsal Mirror Operator 02"}, + } { + identity, identityErr := newIdentity(spec.id, spec.display) + if identityErr != nil { + return identityErr + } + switch spec.id { + case "auditor-01": + auditor1 = identity + case "auditor-02": + auditor2 = identity + } + generated = append(generated, identity) + } } participants := make([]mpcceremony.Participant, 0, participantCount) participantIDs := make([]string, 0, participantCount) @@ -155,13 +155,22 @@ func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (er } } + auditors := []mpcceremony.Identity{} + assurance := &mpcceremony.AssurancePolicy{} + if optionalAssurance { + auditors = []mpcceremony.Identity{auditor1.identity, auditor2.identity} + assurance.PublicWitnessesPerPhase = 1 + assurance.MirrorsPerAcceptedHead = 1 + assurance.PassingCeremonyAudits = 1 + } enrollment := mpcceremony.InitParticipants{ Coordinator: coordinator.identity, ReleaseSigner: releaseSigner.identity, - Auditors: []mpcceremony.Identity{auditor1.identity, auditor2.identity}, + Auditors: auditors, Roster: participants, } policy := mpcceremony.InitPolicy{ + AssurancePolicy: assurance, Phase1Policy: mpcceremony.PhasePolicy{ Participants: participantIDs, Minimum: uint8(participantCount), diff --git a/internal/mpcrehearsal/config_test.go b/internal/mpcrehearsal/config_test.go index 0254deda..4d54bb14 100644 --- a/internal/mpcrehearsal/config_test.go +++ b/internal/mpcrehearsal/config_test.go @@ -8,6 +8,8 @@ import ( "os" "path/filepath" "testing" + + "proof-tool/internal/mpcceremony" ) func TestGenerateUsesShortAutomatedBeaconLead(t *testing.T) { @@ -32,6 +34,32 @@ func TestGenerateUsesShortAutomatedBeaconLead(t *testing.T) { } } +func TestGenerateCanExerciseExplicitlyDisabledOptionalAssurance(t *testing.T) { + root := filepath.Join(t.TempDir(), "rehearsal") + if err := GenerateWithAssurance(root, minRehearsalParticipants, MinimumBeaconLeadSeconds, false); err != nil { + t.Fatal(err) + } + participants, err := mpcceremony.LoadInitParticipants(filepath.Join(root, "config", "participants.json")) + if err != nil { + t.Fatal(err) + } + if participants.Auditors == nil || len(participants.Auditors) != 0 { + t.Fatalf("disabled audit roster = %#v, want explicit empty array", participants.Auditors) + } + policy, err := mpcceremony.LoadInitPolicy(filepath.Join(root, "config", "policy.json")) + if err != nil { + t.Fatal(err) + } + if policy.AssurancePolicy == nil || *policy.AssurancePolicy != (mpcceremony.AssurancePolicy{}) { + t.Fatalf("assurance policy = %#v, want explicit zeroes", policy.AssurancePolicy) + } + for _, id := range []string{"auditor-01", "witness-01", "mirror-01"} { + if _, err := os.Lstat(filepath.Join(root, "keys", id+".ed25519.private.hex")); !os.IsNotExist(err) { + t.Fatalf("disabled role key %q exists or cannot be inspected: %v", id, err) + } + } +} + func TestGenerateRejectsShorterBeaconLead(t *testing.T) { err := Generate(filepath.Join(t.TempDir(), "rehearsal"), minRehearsalParticipants, MinimumBeaconLeadSeconds-1) if err == nil { diff --git a/scripts/mpc-rehearsal-config/main.go b/scripts/mpc-rehearsal-config/main.go index 9c299f53..13bbdf3e 100644 --- a/scripts/mpc-rehearsal-config/main.go +++ b/scripts/mpc-rehearsal-config/main.go @@ -20,6 +20,7 @@ func main() { 300, fmt.Sprintf("signed rehearsal witness/round lead in seconds (minimum %d)", mpcrehearsal.MinimumBeaconLeadSeconds), ) + disableOptionalAssurance := flag.Bool("disable-optional-assurance", false, "sign explicit zero witness, mirror, and ceremony-audit requirements") flag.Parse() if *outDir == "" || flag.NArg() != 0 { fmt.Fprintln(os.Stderr, "usage: mpc-rehearsal-config --out-dir FRESH_DIR [--participants 3]") @@ -29,7 +30,7 @@ func main() { fmt.Fprintln(os.Stderr, "beacon witness lead exceeds uint32") os.Exit(2) } - if err := generate(*outDir, *participantCount, uint32(*beaconWitnessLead)); err != nil { + if err := mpcrehearsal.GenerateWithAssurance(*outDir, *participantCount, uint32(*beaconWitnessLead), !*disableOptionalAssurance); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } diff --git a/scripts/mpc-rehearsal-operational-evidence/main.go b/scripts/mpc-rehearsal-operational-evidence/main.go index d9909565..e9aa1557 100644 --- a/scripts/mpc-rehearsal-operational-evidence/main.go +++ b/scripts/mpc-rehearsal-operational-evidence/main.go @@ -219,15 +219,21 @@ func build( if err != nil { return nil, fmt.Errorf("phase2: %w", err) } + bundleSchema := mpcceremony.OperationalEvidenceBundleSchema + if definition.Schema != mpcceremony.DefinitionSchema { + bundleSchema = mpcceremony.OperationalEvidenceBundleSchemaV2 + } bundle := mpcceremony.OperationalEvidenceBundle{ - Schema: mpcceremony.OperationalEvidenceBundleSchema, - CeremonyID: definition.CeremonyID, - Enrollments: enrollments, - Phase1: phase1.evidence, - Phase2: phase2.evidence, - CoordinatorID: definition.Coordinator.ID, - CoordinatorKeyID: definition.Coordinator.KeyID, - AssembledAt: assembledAt, + Schema: bundleSchema, + CeremonyID: definition.CeremonyID, + AssurancePolicy: cloneRehearsalAssurance(definition), + Enrollments: enrollments, + GovernanceRecords: []mpcceremony.SignedArtifactRefs{}, + Phase1: phase1.evidence, + Phase2: phase2.evidence, + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + AssembledAt: assembledAt, } bundleBytes, bundleSignature, err := mpcceremony.SignRecord( bundle, @@ -294,16 +300,20 @@ func buildPhase( phase mpcceremony.Phase, relayDir string, ) (phaseResult, error) { - policy, err := definition.PolicyForPhase(phase) + phaseName := string(phase) + closeName := phaseName + "/closure/record.json" + closeSignatureName := phaseName + "/closure/record.sig" + closeBytes, err := readRegular(filepath.Join(root, filepath.FromSlash(closeName)), 16<<20) if err != nil { return phaseResult{}, err } - sequence := fmt.Sprintf("%04d", len(policy.Participants)) - phaseName := string(phase) + var closeRecord mpcceremony.CloseRecord + if err := mpcceremony.UnmarshalCanonical(closeBytes, &closeRecord); err != nil { + return phaseResult{}, err + } + sequence := fmt.Sprintf("%04d", closeRecord.FinalIndex) chainName := phaseName + "/chain-" + sequence + ".json" chainSignatureName := phaseName + "/chain-" + sequence + ".sig" - closeName := phaseName + "/closure/record.json" - closeSignatureName := phaseName + "/closure/record.sig" chainBytes, err := readRegular(filepath.Join(root, filepath.FromSlash(chainName)), 16<<20) if err != nil { return phaseResult{}, err @@ -315,10 +325,6 @@ func buildPhase( if err != nil { return phaseResult{}, err } - closeBytes, err := readRegular(filepath.Join(root, filepath.FromSlash(closeName)), 16<<20) - if err != nil { - return phaseResult{}, err - } closeSignatureBytes, err := readRegular( filepath.Join(root, filepath.FromSlash(closeSignatureName)), 16<<20, @@ -330,10 +336,6 @@ func buildPhase( if err := mpcceremony.UnmarshalCanonical(chainBytes, &chain); err != nil { return phaseResult{}, err } - var closeRecord mpcceremony.CloseRecord - if err := mpcceremony.UnmarshalCanonical(closeBytes, &closeRecord); err != nil { - return phaseResult{}, err - } coordinator := signers[definition.Coordinator.ID] heads := make([]mpcceremony.AcceptedHeadOperationalEvidence, len(chain.Records)) for index, chainRecord := range chain.Records { @@ -548,8 +550,11 @@ func buildPhase( slices.SortFunc(mirrorFiles, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) - mirrorPairs := make([]mpcceremony.SignedArtifactRefs, 0, 2) - for _, mirrorID := range []string{"mirror-01", "mirror-02"} { + assurance := rehearsalAssurance(definition) + mirrorCount := int(assurance.MirrorsPerAcceptedHead) + mirrorPairs := make([]mpcceremony.SignedArtifactRefs, 0, mirrorCount) + for mirrorIndex := 1; mirrorIndex <= mirrorCount; mirrorIndex++ { + mirrorID := fmt.Sprintf("mirror-%02d", mirrorIndex) mirror := signers[mirrorID] record, err := mpcceremony.NewImmutableMirrorReceipt( definition.CeremonyID, @@ -591,12 +596,15 @@ func buildPhase( } } - witnessPairs := make([]mpcceremony.SignedArtifactRefs, 0, 2) + assurance := rehearsalAssurance(definition) + witnessCount := int(assurance.PublicWitnessesPerPhase) + witnessPairs := make([]mpcceremony.SignedArtifactRefs, 0, witnessCount) closedAt, err := parseTimestamp("closed_at", closeRecord.ClosedAt) if err != nil { return phaseResult{}, err } - for _, witnessID := range []string{"witness-01", "witness-02"} { + for witnessIndex := 1; witnessIndex <= witnessCount; witnessIndex++ { + witnessID := fmt.Sprintf("witness-%02d", witnessIndex) witness := signers[witnessID] record, err := mpcceremony.NewPublicWitnessReceipt( definition, @@ -693,7 +701,7 @@ func buildPhase( Signature: mpcceremony.ArtifactRef{Name: closeSignatureName, Digest: mpcceremony.NewDigest(closeSignatureBytes)}, }, AcceptedHeads: heads, - PublicWitnessQuorum: 2, + PublicWitnessQuorum: assurance.PublicWitnessesPerPhase, PublicWitnessReceipts: witnessPairs, MultiRelayBeaconEvidence: beaconPair, RawBeaconResponses: rawRefs, @@ -740,7 +748,7 @@ func loadSigners( return nil, err } } - for index, external := range []struct { + externals := []struct { id, display string role mpcceremony.EnrollmentRole }{ @@ -748,7 +756,17 @@ func loadSigners( {"witness-02", "Local Rehearsal Public Witness 02", mpcceremony.EnrollmentPublicWitness}, {"mirror-01", "Local Rehearsal Mirror Operator 01", mpcceremony.EnrollmentMirrorOperator}, {"mirror-02", "Local Rehearsal Mirror Operator 02", mpcceremony.EnrollmentMirrorOperator}, - } { + } + assurance := rehearsalAssurance(definition) + witnessCount := int(assurance.PublicWitnessesPerPhase) + mirrorCount := int(assurance.MirrorsPerAcceptedHead) + for index, external := range externals { + if external.role == mpcceremony.EnrollmentPublicWitness && index >= witnessCount { + continue + } + if external.role == mpcceremony.EnrollmentMirrorOperator && index-2 >= mirrorCount { + continue + } key, publicKey, err := keybundle.LoadExistingPrivateKey( filepath.Join(keysDir, external.id+".ed25519.private.hex"), ) @@ -775,6 +793,28 @@ func loadSigners( return result, nil } +func rehearsalAssurance(definition mpcceremony.CeremonyDefinition) mpcceremony.AssurancePolicy { + if definition.Schema == mpcceremony.DefinitionSchema && definition.AssurancePolicy != nil { + return *definition.AssurancePolicy + } + // This helper historically produced two witness and two mirror records for + // legacy rehearsals. Preserve that stronger old behavior rather than + // interpreting an absent new field as zero. + return mpcceremony.AssurancePolicy{ + PublicWitnessesPerPhase: 2, + MirrorsPerAcceptedHead: 2, + PassingCeremonyAudits: 1, + } +} + +func cloneRehearsalAssurance(definition mpcceremony.CeremonyDefinition) *mpcceremony.AssurancePolicy { + if definition.Schema != mpcceremony.DefinitionSchema || definition.AssurancePolicy == nil { + return nil + } + value := *definition.AssurancePolicy + return &value +} + func loadRelayInputs(directory string) ([]relayInput, error) { directory, err := realDirectory(directory) if err != nil { diff --git a/scripts/mpc-rehearsal-operational-evidence/main_test.go b/scripts/mpc-rehearsal-operational-evidence/main_test.go index 21822b1c..a4b0790d 100644 --- a/scripts/mpc-rehearsal-operational-evidence/main_test.go +++ b/scripts/mpc-rehearsal-operational-evidence/main_test.go @@ -3,8 +3,21 @@ package main import ( "testing" "time" + + "proof-tool/internal/mpcceremony" ) +func TestLegacyDefinitionUsesOldNonzeroObserverDefaults(t *testing.T) { + definition := mpcceremony.CeremonyDefinition{Schema: mpcceremony.DefinitionSchemaV2} + policy := rehearsalAssurance(definition) + if policy.PublicWitnessesPerPhase != 2 || policy.MirrorsPerAcceptedHead != 2 || policy.PassingCeremonyAudits != 1 { + t.Fatalf("legacy rehearsal policy = %#v", policy) + } + if cloneRehearsalAssurance(definition) != nil { + t.Fatal("legacy bundle unexpectedly gained a v3 assurance field") + } +} + func TestTwoInteriorTimestampsSupportsSubsecondAuthenticatedGap(t *testing.T) { lower := time.Date(2026, time.July, 23, 12, 0, 0, 0, time.UTC) upper := lower.Add(100 * time.Millisecond) From 0a6ec7f39527df06b1aebf8c1a60ff3a75759e89 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:53:38 +0900 Subject: [PATCH 63/64] Simplify the V4 storage-first ceremony protocol (#32) * feat: author and accept storage submissions * fix: bind submissions to logical ceremony paths * Preserve released ceremony verification before protocol revision * Add versioned trusted-coordinator checkpoint state and delivery recovery * Verify V4 checkpoint turn artifacts against replayed chain * Exercise V4 checkpoints with a real signed contribution turn * Bind V4 custody evidence and verify phase lifecycle artifacts * Record verified enrollments and per-head mirror evidence in V4 * Enforce checkpointed witness and beacon evidence before sealing * Bind V4 final candidates to complete coordinator replay * Collect checkpointed audits without weakening release quorum * Derive operational evidence from authenticated V4 history * Preserve V4 incidents and enforce terminal abort and restart * Verify exact V4 final review without duplicate contribution replay * Bind V4 public verification to the exact bytes read * Derive V4 review dependencies without historical payload copies * Sign and verify exact V4 release packages with bound coordinator review * Bound V4 release checksums for maximum review inventories * Record exact private V4 release packages in ceremony checkpoints * Bind V4 production decisions to exact verified release packages * Route production decision CLI through authenticated V4 verification * Wire V4 release package signing and verification without duplicate replay * Expose opt-in V4 checkpoint preparation signing and structural inspection * Add checkpoint-bound V4 bundle and release evidence commands * Add authenticated protocol and bounded checkpoint discovery * Cover discovery command wiring and rejected secret flags * Expose head-bound turn commitments and batch enrollment guidance * Inspect retained V4 contribution inventories for safe recovery * Inspect generated V4 contribution files before cleanup signing * Expose exact authenticated definition references for workflow binding * Simplify storage-first ceremony turns * Check checkpoint reader close results * Bind upload grants to allocation checkpoints * Derive initial storage checkpoint in proof tool * Derive signed V4 lifecycle checkpoints * Record signed V4 release review state * Allow cross-platform V4 publication verification * Fix V4 lint findings * Prepare authenticated beacon evidence records * Expose closed final release download inventory * Expose committed beacon evidence by phase * Simplify V4 beacon evidence * Include circuit in initial V4 checkpoint * Encode empty V4 evidence lists explicitly * Fix V4 final release inventory discovery * Fix V4 proof-tool regression coverage * Correct V4 cross-platform signing regression * Add direct V4 candidate rejection checkpoint * Fix V4 checkpoint lint suggestions * Classify semantic V4 candidate failures * Fix V4 checkpoint command regression tests * Classify invalid V4 candidate contents * Test idempotent V4 rejection replay --- cmd/mpc-ceremony/atomic_output.go | 60 ++ cmd/mpc-ceremony/atomic_output_test.go | 36 + cmd/mpc-ceremony/checkpoint_command.go | 95 ++- cmd/mpc-ceremony/checkpoint_command_test.go | 19 +- cmd/mpc-ceremony/checkpoint_v4.go | 532 ++++++++++++ cmd/mpc-ceremony/checkpoint_v4_parse_test.go | 35 + cmd/mpc-ceremony/checkpoint_v4_test.go | 337 ++++++++ cmd/mpc-ceremony/cli_test.go | 37 + cmd/mpc-ceremony/computation_output_v4.go | 27 + cmd/mpc-ceremony/contribution_inventory_v4.go | 73 ++ .../contribution_inventory_v4_test.go | 120 +++ cmd/mpc-ceremony/decision.go | 18 +- cmd/mpc-ceremony/decision_v4.go | 137 ++++ cmd/mpc-ceremony/decision_v4_test.go | 140 ++++ cmd/mpc-ceremony/definition_protocol.go | 34 + cmd/mpc-ceremony/definition_protocol_test.go | 92 +++ cmd/mpc-ceremony/evidence_v4.go | 334 ++++++++ cmd/mpc-ceremony/evidence_v4_test.go | 545 +++++++++++++ cmd/mpc-ceremony/executor.go | 123 ++- cmd/mpc-ceremony/integration_test.go | 46 ++ cmd/mpc-ceremony/journey_inspection.go | 2 +- cmd/mpc-ceremony/main.go | 16 +- cmd/mpc-ceremony/ops.go | 12 +- cmd/mpc-ceremony/ops_bundle.go | 3 + cmd/mpc-ceremony/ops_guided.go | 3 + cmd/mpc-ceremony/parse.go | 47 +- cmd/mpc-ceremony/release_v4.go | 56 ++ cmd/mpc-ceremony/release_v4_test.go | 145 ++++ cmd/mpc-ceremony/secret_boundary_test.go | 5 +- cmd/mpc-ceremony/submission_rename_darwin.go | 12 + cmd/mpc-ceremony/submission_rename_linux.go | 12 + cmd/mpc-ceremony/submission_rename_other.go | 12 + cmd/mpc-ceremony/types.go | 35 + cmd/mpc-ceremony/usage.go | 301 ++++++- docs/ceremony-custody-workflow.md | 9 +- docs/ceremony-schema-compatibility.md | 103 +++ docs/mpc-ceremony-release.md | 6 +- docs/trusted-setup-ceremony.md | 43 +- internal/mpcceremony/audit.go | 220 +++-- internal/mpcceremony/candidate_invalid_v4.go | 85 ++ internal/mpcceremony/chain.go | 49 +- internal/mpcceremony/checkpoint.go | 5 +- internal/mpcceremony/checkpoint_v4.go | 751 +++++++++++++++++ internal/mpcceremony/checkpoint_v4_audits.go | 50 ++ .../mpcceremony/checkpoint_v4_audits_test.go | 65 ++ .../checkpoint_v4_beacon_evidence.go | 74 ++ internal/mpcceremony/checkpoint_v4_bundle.go | 189 +++++ .../mpcceremony/checkpoint_v4_bundle_test.go | 24 + .../mpcceremony/checkpoint_v4_commitments.go | 105 +++ .../checkpoint_v4_commitments_test.go | 107 +++ .../mpcceremony/checkpoint_v4_discovery.go | 64 ++ .../checkpoint_v4_discovery_test.go | 215 +++++ .../checkpoint_v4_enrollment_metadata.go | 77 ++ .../checkpoint_v4_enrollment_metadata_test.go | 78 ++ .../mpcceremony/checkpoint_v4_enrollments.go | 73 ++ .../checkpoint_v4_enrollments_test.go | 128 +++ internal/mpcceremony/checkpoint_v4_files.go | 767 ++++++++++++++++++ .../mpcceremony/checkpoint_v4_files_test.go | 393 +++++++++ internal/mpcceremony/checkpoint_v4_final.go | 66 ++ .../mpcceremony/checkpoint_v4_final_test.go | 51 ++ .../mpcceremony/checkpoint_v4_governance.go | 140 ++++ .../checkpoint_v4_governance_test.go | 191 +++++ .../mpcceremony/checkpoint_v4_initialize.go | 71 ++ .../mpcceremony/checkpoint_v4_lifecycle.go | 160 ++++ internal/mpcceremony/checkpoint_v4_mirrors.go | 91 +++ .../checkpoint_v4_public_outputs.go | 59 ++ internal/mpcceremony/checkpoint_v4_record.go | 116 +++ internal/mpcceremony/checkpoint_v4_release.go | 266 ++++++ .../mpcceremony/checkpoint_v4_release_test.go | 255 ++++++ internal/mpcceremony/checkpoint_v4_review.go | 289 +++++++ .../mpcceremony/checkpoint_v4_review_files.go | 62 ++ .../checkpoint_v4_review_files_test.go | 25 + .../mpcceremony/checkpoint_v4_review_test.go | 109 +++ internal/mpcceremony/checkpoint_v4_test.go | 491 +++++++++++ internal/mpcceremony/checkpoint_v4_turn.go | 444 ++++++++++ internal/mpcceremony/computation_output_v4.go | 101 +++ .../mpcceremony/computation_output_v4_test.go | 73 ++ .../mpcceremony/contribution_allocation_v4.go | 84 ++ .../mpcceremony/contribution_inventory_v4.go | 162 ++++ .../contribution_inventory_v4_test.go | 251 ++++++ internal/mpcceremony/decision.go | 10 +- internal/mpcceremony/decision_v3.go | 357 ++++++++ internal/mpcceremony/decision_v3_draft.go | 69 ++ internal/mpcceremony/decision_v3_test.go | 242 ++++++ internal/mpcceremony/decision_v3_verify.go | 313 +++++++ .../mpcceremony/decision_v3_verify_test.go | 284 +++++++ internal/mpcceremony/definition.go | 139 ++-- internal/mpcceremony/definition_v4_test.go | 153 ++++ internal/mpcceremony/delivery_scope.go | 296 +++++++ internal/mpcceremony/delivery_scope_test.go | 263 ++++++ internal/mpcceremony/files.go | 16 +- internal/mpcceremony/final_transcript_v3.go | 63 ++ .../mpcceremony/final_transcript_v3_test.go | 142 ++++ internal/mpcceremony/model.go | 5 +- internal/mpcceremony/operational.go | 4 +- internal/mpcceremony/operational_builder.go | 2 +- internal/mpcceremony/operational_bundle.go | 465 ++++++----- .../mpcceremony/operational_bundle_test.go | 56 ++ internal/mpcceremony/operational_prepare.go | 4 +- .../mpcceremony/release_checksums_v4_test.go | 60 ++ .../mpcceremony/release_compatibility_test.go | 30 + internal/mpcceremony/release_layout_v4.go | 125 +++ .../mpcceremony/release_layout_v4_test.go | 90 ++ internal/mpcceremony/release_links_other.go | 12 + internal/mpcceremony/release_links_unix.go | 17 + internal/mpcceremony/release_v4.go | 306 +++++++ .../mpcceremony/release_v4_software_test.go | 17 + .../testdata/workflowhelper/checkpoint_v4.go | 455 +++++++++++ .../workflowhelper/checkpoint_v4_final.go | 575 +++++++++++++ .../workflowhelper/checkpoint_v4_release.go | 167 ++++ .../checkpoint_v4_release_checkpoint.go | 124 +++ .../workflowhelper/checkpoint_v4_review.go | 234 ++++++ .../testdata/workflowhelper/main.go | 68 +- internal/mpcceremony/workflow.go | 54 +- 114 files changed, 15507 insertions(+), 448 deletions(-) create mode 100644 cmd/mpc-ceremony/atomic_output.go create mode 100644 cmd/mpc-ceremony/atomic_output_test.go create mode 100644 cmd/mpc-ceremony/checkpoint_v4.go create mode 100644 cmd/mpc-ceremony/checkpoint_v4_parse_test.go create mode 100644 cmd/mpc-ceremony/checkpoint_v4_test.go create mode 100644 cmd/mpc-ceremony/computation_output_v4.go create mode 100644 cmd/mpc-ceremony/contribution_inventory_v4.go create mode 100644 cmd/mpc-ceremony/contribution_inventory_v4_test.go create mode 100644 cmd/mpc-ceremony/decision_v4.go create mode 100644 cmd/mpc-ceremony/decision_v4_test.go create mode 100644 cmd/mpc-ceremony/definition_protocol.go create mode 100644 cmd/mpc-ceremony/definition_protocol_test.go create mode 100644 cmd/mpc-ceremony/evidence_v4.go create mode 100644 cmd/mpc-ceremony/evidence_v4_test.go create mode 100644 cmd/mpc-ceremony/release_v4.go create mode 100644 cmd/mpc-ceremony/release_v4_test.go create mode 100644 cmd/mpc-ceremony/submission_rename_darwin.go create mode 100644 cmd/mpc-ceremony/submission_rename_linux.go create mode 100644 cmd/mpc-ceremony/submission_rename_other.go create mode 100644 docs/ceremony-schema-compatibility.md create mode 100644 internal/mpcceremony/candidate_invalid_v4.go create mode 100644 internal/mpcceremony/checkpoint_v4.go create mode 100644 internal/mpcceremony/checkpoint_v4_audits.go create mode 100644 internal/mpcceremony/checkpoint_v4_audits_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_beacon_evidence.go create mode 100644 internal/mpcceremony/checkpoint_v4_bundle.go create mode 100644 internal/mpcceremony/checkpoint_v4_bundle_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_commitments.go create mode 100644 internal/mpcceremony/checkpoint_v4_commitments_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_discovery.go create mode 100644 internal/mpcceremony/checkpoint_v4_discovery_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_enrollment_metadata.go create mode 100644 internal/mpcceremony/checkpoint_v4_enrollment_metadata_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_enrollments.go create mode 100644 internal/mpcceremony/checkpoint_v4_enrollments_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_files.go create mode 100644 internal/mpcceremony/checkpoint_v4_files_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_final.go create mode 100644 internal/mpcceremony/checkpoint_v4_final_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_governance.go create mode 100644 internal/mpcceremony/checkpoint_v4_governance_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_initialize.go create mode 100644 internal/mpcceremony/checkpoint_v4_lifecycle.go create mode 100644 internal/mpcceremony/checkpoint_v4_mirrors.go create mode 100644 internal/mpcceremony/checkpoint_v4_public_outputs.go create mode 100644 internal/mpcceremony/checkpoint_v4_record.go create mode 100644 internal/mpcceremony/checkpoint_v4_release.go create mode 100644 internal/mpcceremony/checkpoint_v4_release_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_review.go create mode 100644 internal/mpcceremony/checkpoint_v4_review_files.go create mode 100644 internal/mpcceremony/checkpoint_v4_review_files_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_review_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_turn.go create mode 100644 internal/mpcceremony/computation_output_v4.go create mode 100644 internal/mpcceremony/computation_output_v4_test.go create mode 100644 internal/mpcceremony/contribution_allocation_v4.go create mode 100644 internal/mpcceremony/contribution_inventory_v4.go create mode 100644 internal/mpcceremony/contribution_inventory_v4_test.go create mode 100644 internal/mpcceremony/decision_v3.go create mode 100644 internal/mpcceremony/decision_v3_draft.go create mode 100644 internal/mpcceremony/decision_v3_test.go create mode 100644 internal/mpcceremony/decision_v3_verify.go create mode 100644 internal/mpcceremony/decision_v3_verify_test.go create mode 100644 internal/mpcceremony/definition_v4_test.go create mode 100644 internal/mpcceremony/delivery_scope.go create mode 100644 internal/mpcceremony/delivery_scope_test.go create mode 100644 internal/mpcceremony/final_transcript_v3.go create mode 100644 internal/mpcceremony/final_transcript_v3_test.go create mode 100644 internal/mpcceremony/release_checksums_v4_test.go create mode 100644 internal/mpcceremony/release_compatibility_test.go create mode 100644 internal/mpcceremony/release_layout_v4.go create mode 100644 internal/mpcceremony/release_layout_v4_test.go create mode 100644 internal/mpcceremony/release_links_other.go create mode 100644 internal/mpcceremony/release_links_unix.go create mode 100644 internal/mpcceremony/release_v4.go create mode 100644 internal/mpcceremony/release_v4_software_test.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release_checkpoint.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go diff --git a/cmd/mpc-ceremony/atomic_output.go b/cmd/mpc-ceremony/atomic_output.go new file mode 100644 index 00000000..b10c8a09 --- /dev/null +++ b/cmd/mpc-ceremony/atomic_output.go @@ -0,0 +1,60 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "slices" +) + +// writeAtomicOutputDir publishes a small closed set of result files with a +// no-replace directory rename. A byte-identical completed result is an +// idempotent success; incomplete or conflicting output is never overwritten. +func writeAtomicOutputDir(outDir string, files map[string][]byte) (err error) { + parent := filepath.Dir(outDir) + if err := os.MkdirAll(parent, 0o700); err != nil { + return err + } + if _, err := os.Lstat(outDir); err == nil { + info, statErr := os.Lstat(outDir) + if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("atomic output path exists but is not a regular directory") + } + for name, expected := range files { + actual, readErr := readRegularOperationalFile(filepath.Join(outDir, name), maxOperationalRecordBytes) + if readErr != nil || !slices.Equal(actual, expected) { + return errors.New("atomic output already exists with conflicting or incomplete contents") + } + } + entries, readErr := os.ReadDir(outDir) + if readErr != nil || len(entries) != len(files) { + return errors.New("atomic output already exists with conflicting or incomplete contents") + } + return nil + } else if !os.IsNotExist(err) { + return err + } + tmp, err := os.MkdirTemp(parent, ".atomic-output-*") + if err != nil { + return err + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(tmp) + } + }() + for name, data := range files { + if err := writeFreshOperationalFile(filepath.Join(tmp, name), data, 0o600); err != nil { + return err + } + } + if err := syncDirectory(tmp); err != nil { + return err + } + if err := renameDirectoryNoReplace(tmp, outDir); err != nil { + return err + } + complete = true + return syncDirectory(parent) +} diff --git a/cmd/mpc-ceremony/atomic_output_test.go b/cmd/mpc-ceremony/atomic_output_test.go new file mode 100644 index 00000000..f6a77f14 --- /dev/null +++ b/cmd/mpc-ceremony/atomic_output_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAtomicOutputDirectoryIsIdempotentAndClosed(t *testing.T) { + out := filepath.Join(t.TempDir(), "result") + files := map[string][]byte{"checkpoint.json": []byte("checkpoint"), "checkpoint.sig": []byte("signature")} + if err := writeAtomicOutputDir(out, files); err != nil { + t.Fatal(err) + } + if err := writeAtomicOutputDir(out, files); err != nil { + t.Fatalf("byte-identical retry: %v", err) + } + if err := os.WriteFile(filepath.Join(out, "checkpoint.sig"), []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + if err := writeAtomicOutputDir(out, files); err == nil || !strings.Contains(err.Error(), "conflicting or incomplete") { + t.Fatalf("conflicting retry = %v", err) + } +} + +func TestAtomicOutputFailureDoesNotPublishDirectory(t *testing.T) { + parent := t.TempDir() + out := filepath.Join(parent, "result") + if err := writeAtomicOutputDir(out, map[string][]byte{"nested/file": []byte("invalid")}); err == nil { + t.Fatal("invalid nested output succeeded") + } + if _, err := os.Lstat(out); !os.IsNotExist(err) { + t.Fatalf("failed output was published: %v", err) + } +} diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 1bc91184..5e6eaa1e 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -28,6 +28,15 @@ type builtCheckpointEvidence struct { request mpcceremony.CheckpointSigningRequest } +type checkpointAcceptanceSigner func( + trusted *mpcceremony.TrustedCeremony, + checkpoint mpcceremony.Checkpoint, + slot mpcceremony.CheckpointSubmissionSlot, + envelope mpcceremony.SubmissionEnvelopeV1, + envelopeRefs mpcceremony.SignedArtifactRefs, + manifest mpcceremony.ArtifactRef, +) ([]byte, []byte, mpcceremony.SignedArtifactRefs, error) + func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, &usageError{message: "missing checkpoint command", topic: []string{"checkpoint"}} @@ -36,6 +45,14 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"checkpoint"}, args[1:]...)} } switch args[0] { + case "verify-release-v4": + options, err := parseEvidenceV4(CommandCheckpointVerifyReleaseV4, args[1:]) + invocation.Command, invocation.Options = CommandCheckpointVerifyReleaseV4, options + return invocation, wrapCommandError(err, "checkpoint", args[0]) + case "prepare-v4", "sign-v4", "initialize-v4", "record-v4", "allocate-v4", "accept-candidate-v4", "reject-candidate-v4", "verify-stored-v4", "inspect-signed-v4", "inspect-enrollments-v4": + options, err := parseCheckpointV4(args[0], args[1:]) + invocation.Command, invocation.Options = Command("checkpoint "+args[0]), options + return invocation, wrapCommandError(err, "checkpoint", args[0]) case "prepare": options, err := parseCheckpointPrepare(args[1:]) invocation.Command, invocation.Options = CommandCheckpointPrepare, options @@ -580,6 +597,9 @@ func buildCheckpointEvidenceWithParent(options CheckpointEvidenceOptions, verify if err != nil { return builtCheckpointEvidence{}, err } + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return builtCheckpointEvidence{}, errors.New("definition v4 requires the explicit V4 checkpoint commands") + } definitionRefs, err := checkpointPairRefs(options.ArtifactRoot, options.CeremonyPath, options.CeremonySignaturePath) if err != nil { return builtCheckpointEvidence{}, fmt.Errorf("definition references: %w", err) @@ -1372,7 +1392,7 @@ func buildPhase1ClosedCheckpoint(options CheckpointEvidenceOptions, trusted *mpc } func checkpointSchemaForDefinition(definition mpcceremony.CeremonyDefinition) string { - if definition.Schema == mpcceremony.DefinitionSchema { + if definition.Schema == mpcceremony.DefinitionSchemaV3 { return mpcceremony.CheckpointSchema } return mpcceremony.CheckpointSchemaV1 @@ -1446,6 +1466,9 @@ func buildReceiptCheckpoint(options CheckpointEvidenceOptions, trusted *mpccerem if err != nil { return builtCheckpointEvidence{}, err } + if err := requireSubmissionEnvelopeNames(slot, envelopeRefs); err != nil { + return builtCheckpointEvidence{}, err + } envelope, err := mpcceremony.VerifySignedSubmissionEnvelope(trusted.Definition, previous, slot, envelopeBytes, envelopeSignatureBytes) if err != nil { return builtCheckpointEvidence{}, err @@ -1453,6 +1476,9 @@ func buildReceiptCheckpoint(options CheckpointEvidenceOptions, trusted *mpccerem if envelope.Kind != mpcceremony.CheckpointSubmissionReceipt { return builtCheckpointEvidence{}, errors.New("receipt-accepted checkpoint requires a receipt submission envelope") } + if err := requireReceiptPayloadNames(slot, envelope.Payloads); err != nil { + return builtCheckpointEvidence{}, err + } if err := verifyReceiptEnvelopePayloads(options.ArtifactRoot, trusted, previous, envelope); err != nil { return builtCheckpointEvidence{}, err } @@ -1460,7 +1486,13 @@ func buildReceiptCheckpoint(options CheckpointEvidenceOptions, trusted *mpccerem if err != nil { return builtCheckpointEvidence{}, fmt.Errorf("submission manifest: %w", err) } - ackBytes, ackSignatureBytes, ackRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.AcknowledgementPath, options.AcknowledgementSignaturePath) + var ackBytes, ackSignatureBytes []byte + var ackRefs mpcceremony.SignedArtifactRefs + if options.AcceptanceSigner != nil { + ackBytes, ackSignatureBytes, ackRefs, err = options.AcceptanceSigner(trusted, previous, slot, envelope, envelopeRefs, manifest) + } else { + ackBytes, ackSignatureBytes, ackRefs, err = checkpointAcknowledgementBytes(options) + } if err != nil { return builtCheckpointEvidence{}, fmt.Errorf("submission acknowledgement: %w", err) } @@ -1539,6 +1571,9 @@ func buildCandidateCheckpoint(options CheckpointEvidenceOptions, trusted *mpccer if err != nil { return builtCheckpointEvidence{}, err } + if err := requireSubmissionEnvelopeNames(slot, envelopeRefs); err != nil { + return builtCheckpointEvidence{}, err + } if slot.Kind != mpcceremony.CheckpointSubmissionCandidate { return builtCheckpointEvidence{}, errors.New("candidate-accepted checkpoint requires a candidate submission slot") } @@ -1558,7 +1593,13 @@ func buildCandidateCheckpoint(options CheckpointEvidenceOptions, trusted *mpccer if err != nil { return builtCheckpointEvidence{}, fmt.Errorf("candidate manifest: %w", err) } - ackBytes, ackSignatureBytes, ackRefs, err := checkpointSignedBytes(options.ArtifactRoot, options.AcknowledgementPath, options.AcknowledgementSignaturePath) + var ackBytes, ackSignatureBytes []byte + var ackRefs mpcceremony.SignedArtifactRefs + if options.AcceptanceSigner != nil { + ackBytes, ackSignatureBytes, ackRefs, err = options.AcceptanceSigner(trusted, previous, slot, envelope, envelopeRefs, manifest) + } else { + ackBytes, ackSignatureBytes, ackRefs, err = checkpointAcknowledgementBytes(options) + } if err != nil { return builtCheckpointEvidence{}, fmt.Errorf("candidate acknowledgement: %w", err) } @@ -1787,6 +1828,29 @@ func findAllocatedSubmission(checkpoint mpcceremony.Checkpoint, envelope mpccere return mpcceremony.CheckpointSubmissionSlot{}, errors.New("submission envelope does not match an allocated checkpoint slot") } +func requireSubmissionEnvelopeNames(slot mpcceremony.CheckpointSubmissionSlot, refs mpcceremony.SignedArtifactRefs) error { + base := strings.TrimSuffix(slot.ManifestKey, "/manifest.json") + if refs.Record.Name != base+"/envelope.json" || refs.Signature.Name != base+"/envelope.sig" { + return errors.New("submission envelope does not use the preallocated storage path") + } + return nil +} + +func requireReceiptPayloadNames(slot mpcceremony.CheckpointSubmissionSlot, refs []mpcceremony.ArtifactRef) error { + base := fmt.Sprintf("%s/custody/%04d", slot.Phase, slot.Index) + want := []string{base + "/outbound-receipt.json", base + "/outbound-receipt.sig"} + got := make([]string, len(refs)) + for i := range refs { + got[i] = refs[i].Name + } + slices.Sort(got) + slices.Sort(want) + if !slices.Equal(got, want) { + return errors.New("receipt submission does not use the deterministic ceremony evidence paths") + } + return nil +} + func checkpointSignedBytes(root, recordPath, signaturePath string) ([]byte, []byte, mpcceremony.SignedArtifactRefs, error) { recordBytes, recordRef, err := checkpointArtifactBytes(root, recordPath, maxOperationalRecordBytes) if err != nil { @@ -1800,6 +1864,31 @@ func checkpointSignedBytes(root, recordPath, signaturePath string) ([]byte, []by return recordBytes, signatureBytes, refs, nil } +func checkpointAcknowledgementBytes(options CheckpointEvidenceOptions) ([]byte, []byte, mpcceremony.SignedArtifactRefs, error) { + if options.AcknowledgementRecordName == "" && options.AcknowledgementSignatureName == "" { + return checkpointSignedBytes(options.ArtifactRoot, options.AcknowledgementPath, options.AcknowledgementSignaturePath) + } + if options.AcknowledgementRecordName == "" || options.AcknowledgementSignatureName == "" { + return nil, nil, mpcceremony.SignedArtifactRefs{}, errors.New("both intended acknowledgement names are required") + } + record, err := readRegularOperationalFile(options.AcknowledgementPath, maxOperationalRecordBytes) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + signature, err := readRegularOperationalFile(options.AcknowledgementSignaturePath, 4096) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + refs := mpcceremony.SignedArtifactRefs{ + Record: mpcceremony.ArtifactRef{Name: options.AcknowledgementRecordName, Digest: mpcceremony.NewDigest(record)}, + Signature: mpcceremony.ArtifactRef{Name: options.AcknowledgementSignatureName, Digest: mpcceremony.NewDigest(signature)}, + } + if err := refs.Validate(); err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + return record, signature, refs, nil +} + func requireCheckpointArtifactName(ref mpcceremony.ArtifactRef, expected, label string) error { if ref.Name != expected { return fmt.Errorf("%s must use canonical storage path %q, got %q", label, expected, ref.Name) diff --git a/cmd/mpc-ceremony/checkpoint_command_test.go b/cmd/mpc-ceremony/checkpoint_command_test.go index 3d90eba0..071dcf92 100644 --- a/cmd/mpc-ceremony/checkpoint_command_test.go +++ b/cmd/mpc-ceremony/checkpoint_command_test.go @@ -187,8 +187,8 @@ func TestCheckpointPrepareReceiptAcceptedAuthenticatesInnerEvidence(t *testing.T if err != nil { t.Fatal(err) } - receiptPath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "receipt.json") - receiptSignaturePath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "receipt.sig") + receiptPath := filepath.Join(fixture.root, "phase1", "custody", "0001", "outbound-receipt.json") + receiptSignaturePath := filepath.Join(fixture.root, "phase1", "custody", "0001", "outbound-receipt.sig") if err := os.MkdirAll(filepath.Dir(receiptPath), 0o700); err != nil { t.Fatal(err) } @@ -221,6 +221,9 @@ func TestCheckpointPrepareReceiptAcceptedAuthenticatesInnerEvidence(t *testing.T } envelopePath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "envelope.json") envelopeSignaturePath := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID, "envelope.sig") + if err := os.MkdirAll(filepath.Dir(envelopePath), 0o700); err != nil { + t.Fatal(err) + } envelopeBytes, envelopeSignatureBytes, err := mpcceremony.SignSubmissionEnvelope(fixture.definition, cp1, slot, envelope, participantKey) if err != nil { t.Fatal(err) @@ -785,7 +788,7 @@ func prepareAndSignCandidateCheckpoint(t *testing.T, fixture checkpointCLIFixtur ParentCheckpointSHA256: slot.BasisCheckpointSHA256, AllocationCheckpointSHA256: mpcceremony.NewDigest(previousBytes).SHA256, ParentHeadID: slot.ParentHeadID, AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, Payloads: payloads, } - envelopeDir := filepath.Join(fixture.root, "submissions", string(phase)+"-candidate", slot.AttemptID) + envelopeDir := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) if err := os.MkdirAll(envelopeDir, 0o700); err != nil { t.Fatal(err) } @@ -1037,11 +1040,11 @@ func prepareAndSignReceiptCheckpoint(t *testing.T, fixture checkpointCLIFixture, if err != nil { t.Fatal(err) } - receiptDir := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID) + receiptDir := filepath.Join(fixture.root, string(phase), "custody", fmt.Sprintf("%04d", slot.Index)) if err := os.MkdirAll(receiptDir, 0o700); err != nil { t.Fatal(err) } - receiptPath, receiptSignaturePath := filepath.Join(receiptDir, "receipt.json"), filepath.Join(receiptDir, "receipt.sig") + receiptPath, receiptSignaturePath := filepath.Join(receiptDir, "outbound-receipt.json"), filepath.Join(receiptDir, "outbound-receipt.sig") receiptBytes, receiptSignatureBytes, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) if err != nil { t.Fatal(err) @@ -1071,7 +1074,11 @@ func prepareAndSignReceiptCheckpoint(t *testing.T, fixture checkpointCLIFixture, AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, Payloads: checkpointSortedArtifacts(receiptRef, receiptSignatureRef), } - envelopePath, envelopeSignaturePath := filepath.Join(receiptDir, "envelope.json"), filepath.Join(receiptDir, "envelope.sig") + envelopeDir := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) + if err := os.MkdirAll(envelopeDir, 0o700); err != nil { + t.Fatal(err) + } + envelopePath, envelopeSignaturePath := filepath.Join(envelopeDir, "envelope.json"), filepath.Join(envelopeDir, "envelope.sig") envelopeBytes, envelopeSignatureBytes, err := mpcceremony.SignSubmissionEnvelope(fixture.definition, cp1, slot, envelope, participantKey) if err != nil { t.Fatal(err) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go new file mode 100644 index 00000000..3ed999f2 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -0,0 +1,532 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "path/filepath" + + "proof-tool/internal/keybundle" + m "proof-tool/internal/mpcceremony" +) + +type CheckpointOptionsV4 struct { + InspectDefinitionOptions + ArtifactRoot, ProposalPath, RejectedCandidateDir string + CheckpointPath, CheckpointSignaturePath string + CoordinatorSigningKey, OutPath, OutDir string + AttemptID, AllocatedAt, AcceptedAt, CandidateDir string + TransitionKind, RecordPath, RecordSignaturePath string + EvidencePaths []string +} + +type CheckpointInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Checkpoint m.CheckpointV4 `json:"checkpoint"` + CheckpointRefs m.SignedArtifactRefs `json:"checkpoint_refs"` + Commitments m.CheckpointCommitmentsV4 `json:"commitments"` + ArtifactsVerified bool `json:"artifacts_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` +} + +type CheckpointDiscoveryInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Discovery m.CheckpointDiscoveryV4 `json:"discovery"` + CheckpointRefs m.SignedArtifactRefs `json:"checkpoint_refs"` + AncestryVerified bool `json:"ancestry_verified"` + ArtifactsVerified bool `json:"artifacts_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` +} + +type EnrollmentMetadataInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Metadata m.EnrollmentMetadataV4 `json:"metadata"` + EnrollmentSignaturesVerified bool `json:"enrollment_signatures_verified"` + DisclosureContentsVerified bool `json:"disclosure_contents_verified"` + CompleteRosterVerified bool `json:"complete_roster_verified"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` +} + +func checkpointReadOnlyActionV4(action string) bool { + return action == "verify-stored-v4" || action == "inspect-signed-v4" || action == "inspect-enrollments-v4" +} + +func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error) { + var o CheckpointOptionsV4 + fs := commandFlagSet("checkpoint " + action) + addCeremonyTrustFlags(fs, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + fs.StringVar(&o.ArtifactRoot, "artifact-root", "", "local root containing protocol artifacts") + if checkpointReadOnlyActionV4(action) || action == "initialize-v4" || action == "record-v4" || action == "allocate-v4" || action == "accept-candidate-v4" || action == "reject-candidate-v4" { + if action == "initialize-v4" { + fs.StringVar(&o.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") + fs.StringVar(&o.OutDir, "out-dir", "", "fresh atomic output directory for the signed initial checkpoint pair") + } else { + fs.StringVar(&o.CheckpointPath, "checkpoint", "", "exact checkpoint under artifact-root") + fs.StringVar(&o.CheckpointSignaturePath, "checkpoint-signature", "", "exact detached checkpoint signature under artifact-root") + if action == "record-v4" { + fs.StringVar(&o.TransitionKind, "transition", "", "record-backed V4 transition kind") + fs.StringVar(&o.RecordPath, "record", "", "exact signed protocol record under artifact-root") + fs.StringVar(&o.RecordSignaturePath, "record-signature", "", "detached protocol record signature under artifact-root") + fs.Var((*stringList)(&o.EvidencePaths), "evidence", "exact evidence file under artifact-root; repeat for every required file") + fs.StringVar(&o.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") + fs.StringVar(&o.OutDir, "out-dir", "", "fresh atomic output directory for the signed descendant checkpoint pair") + } + if action == "allocate-v4" || action == "accept-candidate-v4" || action == "reject-candidate-v4" { + fs.StringVar(&o.AttemptID, "attempt-id", "", "fresh 32-character hexadecimal delivery attempt ID") + fs.StringVar(&o.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") + fs.StringVar(&o.OutDir, "out-dir", "", "fresh atomic output directory for the signed checkpoint pair") + } + if action == "allocate-v4" { + fs.StringVar(&o.AllocatedAt, "allocated-at", "", "allocation time in RFC3339 format") + } + if action == "accept-candidate-v4" { + fs.StringVar(&o.CandidateDir, "candidate-dir", "", "exact complete candidate directory") + fs.StringVar(&o.AcceptedAt, "accepted-at", "", "acceptance time in RFC3339 format") + } + if action == "reject-candidate-v4" { + fs.StringVar(&o.RejectedCandidateDir, "rejected-candidate-dir", "", "exact private rejected candidate directory") + } + } + } else { + fs.StringVar(&o.ProposalPath, "proposal", "", "exact canonical V4 checkpoint proposal") + fs.StringVar(&o.RejectedCandidateDir, "rejected-candidate-dir", "", "private candidate directory required only for contribution-rejected") + fs.StringVar(&o.OutPath, "out", "", "fresh output file; parent must exist") + if action == "sign-v4" { + fs.StringVar(&o.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") + } + } + if err := parseFlags(fs, args); err != nil { + return o, err + } + if err := requireValues(pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--artifact-root", o.ArtifactRoot)); err != nil { + return o, err + } + if checkpointReadOnlyActionV4(action) { + return o, requireValues(pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath)) + } + if action == "initialize-v4" { + return o, requireValues(pathValue("--coordinator-signing-key", o.CoordinatorSigningKey), pathValue("--out-dir", o.OutDir)) + } + if action == "record-v4" { + return o, requireValues(pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath), value("--transition", o.TransitionKind), pathValue("--record", o.RecordPath), pathValue("--record-signature", o.RecordSignaturePath), pathValue("--coordinator-signing-key", o.CoordinatorSigningKey), pathValue("--out-dir", o.OutDir)) + } + if action == "allocate-v4" || action == "accept-candidate-v4" || action == "reject-candidate-v4" { + if err := requireValues(pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath), value("--attempt-id", o.AttemptID), pathValue("--coordinator-signing-key", o.CoordinatorSigningKey), pathValue("--out-dir", o.OutDir)); err != nil { + return o, err + } + if action == "allocate-v4" { + return o, requireValues(value("--allocated-at", o.AllocatedAt)) + } + if action == "reject-candidate-v4" { + return o, requireValues(pathValue("--rejected-candidate-dir", o.RejectedCandidateDir)) + } + return o, requireValues(pathValue("--candidate-dir", o.CandidateDir), value("--accepted-at", o.AcceptedAt)) + } + if action != "prepare-v4" && action != "sign-v4" { + return o, errors.New("unknown V4 checkpoint action") + } + if o.RejectedCandidateDir != "" { + if err := validatePathValue("--rejected-candidate-dir", o.RejectedCandidateDir); err != nil { + return o, err + } + } + if err := requireValues(pathValue("--proposal", o.ProposalPath), pathValue("--out", o.OutPath)); err != nil { + return o, err + } + if action == "sign-v4" { + return o, requireValues(pathValue("--coordinator-signing-key", o.CoordinatorSigningKey)) + } + return o, nil +} + +func checkpointNeedsCircuitV4(kind m.CheckpointTransitionKind) (bool, error) { + switch kind { + case m.CheckpointInitial, m.CheckpointPhase1CandidateAccepted, m.CheckpointPhase2CandidateAccepted, + m.CheckpointPhase1Sealed, m.CheckpointPhase2Initialized, m.CheckpointFinalCandidateRecorded: + return true, nil + case m.CheckpointPhase1CandidateAllocated, m.CheckpointPhase2CandidateAllocated, + m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, + m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, + m.CheckpointReleaseReviewRecorded, m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, + m.CheckpointWitnessRecorded, m.CheckpointAuditRecorded, + m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted: + return false, nil + default: + return false, fmt.Errorf("unclassified V4 checkpoint transition %q", kind) + } +} + +func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, error) { + trust := trustPaths(o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile) + trusted, err := m.LoadSignedDefinition(trust) + if err != nil { + return CommandResult{}, err + } + d := trusted.Definition + if d.Schema != m.DefinitionSchemaV4 { + return CommandResult{}, errors.New("V4 checkpoint commands require definition v4") + } + if err := m.VerifyRunningSoftwareForMode(d.Software, d.Mode); err != nil { + return CommandResult{}, err + } + if o.OutDir != "" { + if err := validateCheckpointAtomicOutputV4(o); err != nil { + return CommandResult{}, err + } + } + if command == CommandCheckpointInitializeV4 { + circuit, err := loadCheckpointCircuitV4(o.ArtifactRoot, d) + if err != nil { + return CommandResult{}, err + } + prepared, err := m.PrepareInitialCheckpointV4(m.InitialCheckpointV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: o.ArtifactRoot}) + if err != nil { + return CommandResult{}, err + } + private, public, err := keybundle.LoadExistingPrivateKey(o.CoordinatorSigningKey) + if err != nil { + return CommandResult{}, err + } + if !bytes.Equal(public, trusted.CoordinatorPublicKey) { + return CommandResult{}, errors.New("checkpoint signing key is not the authenticated coordinator key") + } + signature, err := m.SignExact(prepared.Canonical, d.Coordinator.KeyID, private) + if err != nil { + return CommandResult{}, err + } + signatureBytes, err := m.MarshalCanonical(signature) + if err != nil { + return CommandResult{}, err + } + if err := writeAtomicOutputDir(o.OutDir, map[string][]byte{"checkpoint.json": prepared.Canonical, "checkpoint.sig": signatureBytes}); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: d.CeremonyID, Phase: string(m.Phase1), Sequence: 0, Summary: "derived and signed the initial checkpoint from the authenticated definition and replayed genesis chain; it is not current until the delivery service publishes it", Outputs: map[string]string{"checkpoint": filepath.Join(o.OutDir, "checkpoint.json"), "checkpoint_signature": filepath.Join(o.OutDir, "checkpoint.sig")}}, nil + } + if command == CommandCheckpointRecordV4 { + _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) + if err != nil { + return CommandResult{}, err + } + record, err := checkpointPairRefs(o.ArtifactRoot, o.RecordPath, o.RecordSignaturePath) + if err != nil { + return CommandResult{}, err + } + evidence := make([]m.ArtifactRef, 0, len(o.EvidencePaths)) + for _, path := range o.EvidencePaths { + ref, err := checkpointArtifactRef(o.ArtifactRoot, path) + if err != nil { + return CommandResult{}, err + } + evidence = append(evidence, ref) + } + kind := m.CheckpointTransitionKind(o.TransitionKind) + var circuit *m.CompiledCircuit + if needed, err := checkpointNeedsCircuitV4(kind); err != nil { + return CommandResult{}, err + } else if needed { + circuit, err = loadCheckpointCircuitV4(o.ArtifactRoot, d) + if err != nil { + return CommandResult{}, err + } + } + prepared, err := m.PrepareRecordedCheckpointV4(m.RecordedCheckpointV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: o.ArtifactRoot, Checkpoint: refs, Kind: kind, Record: record, Evidence: evidence}) + if err != nil { + return CommandResult{}, err + } + private, public, err := keybundle.LoadExistingPrivateKey(o.CoordinatorSigningKey) + if err != nil { + return CommandResult{}, err + } + if !bytes.Equal(public, trusted.CoordinatorPublicKey) { + return CommandResult{}, errors.New("checkpoint signing key is not the authenticated coordinator key") + } + signed, err := m.SignExact(prepared.Canonical, d.Coordinator.KeyID, private) + if err != nil { + return CommandResult{}, err + } + signatureBytes, err := m.MarshalCanonical(signed) + if err != nil { + return CommandResult{}, err + } + if err := writeAtomicOutputDir(o.OutDir, map[string][]byte{"checkpoint.json": prepared.Canonical, "checkpoint.sig": signatureBytes}); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: d.CeremonyID, Sequence: int(prepared.Checkpoint.Sequence), Summary: "verified the exact signed protocol record and derived its signed descendant checkpoint; it is not current until the delivery service publishes it", Outputs: map[string]string{"checkpoint": filepath.Join(o.OutDir, "checkpoint.json"), "checkpoint_signature": filepath.Join(o.OutDir, "checkpoint.sig")}}, nil + } + if command == CommandCheckpointAllocateV4 || command == CommandCheckpointAcceptCandidateV4 || command == CommandCheckpointRejectCandidateV4 { + _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) + if err != nil { + return CommandResult{}, err + } + private, public, err := keybundle.LoadExistingPrivateKey(o.CoordinatorSigningKey) + if err != nil { + return CommandResult{}, err + } + if !bytes.Equal(public, trusted.CoordinatorPublicKey) { + return CommandResult{}, errors.New("checkpoint signing key is not the authenticated coordinator key") + } + var canonical []byte + var phase string + var sequence uint64 + switch command { + case CommandCheckpointAllocateV4: + prepared, err := m.PrepareCandidateAllocationCheckpointV4(m.CandidateAllocationCheckpointV4Options{Trust: trust, ArtifactRoot: o.ArtifactRoot, Checkpoint: refs, AttemptID: o.AttemptID, AllocatedAt: o.AllocatedAt}) + if err != nil { + return CommandResult{}, err + } + canonical, phase, sequence = prepared.Canonical, string(prepared.Scope.Phase), prepared.Checkpoint.Sequence + case CommandCheckpointAcceptCandidateV4: + circuit, err := loadCheckpointCircuitV4(o.ArtifactRoot, d) + if err != nil { + return CommandResult{}, err + } + prepared, err := m.VerifyAndAcceptAllocatedCandidateV4(m.AcceptAllocatedCandidateV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: o.ArtifactRoot, Checkpoint: refs, AttemptID: o.AttemptID, CandidateDir: o.CandidateDir, CoordinatorPrivateKeyPath: o.CoordinatorSigningKey, AcceptedAt: o.AcceptedAt}) + if err != nil { + return CommandResult{}, err + } + canonical, phase, sequence = prepared.Canonical, string(prepared.Scope.Phase), prepared.Checkpoint.Sequence + case CommandCheckpointRejectCandidateV4: + prepared, err := m.RejectAllocatedCandidateV4(m.RejectAllocatedCandidateV4Options{Trust: trust, ArtifactRoot: o.ArtifactRoot, Checkpoint: refs, AttemptID: o.AttemptID, RejectedCandidateDir: o.RejectedCandidateDir}) + if err != nil { + return CommandResult{}, err + } + canonical, phase, sequence = prepared.Canonical, string(prepared.Scope.Phase), prepared.Checkpoint.Sequence + } + signature, err := m.SignExact(canonical, d.Coordinator.KeyID, private) + if err != nil { + return CommandResult{}, err + } + signatureBytes, err := m.MarshalCanonical(signature) + if err != nil { + return CommandResult{}, err + } + if err := writeAtomicOutputDir(o.OutDir, map[string][]byte{"checkpoint.json": canonical, "checkpoint.sig": signatureBytes}); err != nil { + return CommandResult{}, err + } + action := "" + switch command { + case CommandCheckpointAllocateV4: + action = "allocated the exact next candidate turn" + case CommandCheckpointAcceptCandidateV4: + action = "verified and accepted the exact allocated candidate" + case CommandCheckpointRejectCandidateV4: + action = "recorded the exact rejected candidate and retired its allocation; a replacement requires a fresh contribution" + } + return CommandResult{CeremonyID: d.CeremonyID, Phase: phase, Sequence: int(sequence), Summary: action + "; the signed checkpoint is not current until the delivery service conditionally publishes it", Outputs: map[string]string{"checkpoint": filepath.Join(o.OutDir, "checkpoint.json"), "checkpoint_signature": filepath.Join(o.OutDir, "checkpoint.sig")}}, nil + } + if command == CommandCheckpointInspectEnrollmentsV4 { + _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) + if err != nil { + return CommandResult{}, err + } + checkpoint, commitments, metadata, err := m.InspectCheckpointGuidanceV4(trust, o.ArtifactRoot, refs) + if err != nil { + return CommandResult{}, err + } + if metadata.CeremonyID != d.CeremonyID || metadata.Checkpoint != refs { + return CommandResult{}, errors.New("authenticated enrollment metadata changed ceremony or head") + } + return CommandResult{CeremonyID: d.CeremonyID, Summary: "Verified checkpoint ancestry and its exact committed enrollment signatures and identities. Disclosure contents and required roster completeness were not checked.", CheckpointInspectionV4: &CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: checkpoint, CheckpointRefs: refs, Commitments: commitments}, EnrollmentMetadataV4: &EnrollmentMetadataInspectionV4{Schema: "proof-tool-mpc-enrollment-metadata-v4", Depth: "committed-enrollment-signatures", Metadata: metadata, EnrollmentSignaturesVerified: true}}, nil + } + if command == CommandCheckpointInspectSignedV4 { + record, signature, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) + if err != nil { + return CommandResult{}, err + } + db, err := m.MarshalCanonical(d) + if err != nil { + return CommandResult{}, err + } + ds, err := readRegularOperationalFile(o.CeremonySignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + discovery, err := m.DiscoverSignedCheckpointV4(d, db, ds, record, signature) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: d.CeremonyID, + Summary: "Authenticated one checkpoint for file discovery only. Ancestry, referenced evidence, contribution mathematics and freshness are not verified.", + CheckpointDiscoveryV4: &CheckpointDiscoveryInspectionV4{Schema: "proof-tool-mpc-checkpoint-discovery-v4", Depth: "signed-checkpoint-discovery", Discovery: discovery, CheckpointRefs: refs}}, nil + } + if command == CommandCheckpointVerifyStoredV4 { + _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) + if err != nil { + return CommandResult{}, err + } + c, commitments, err := m.InspectStoredCheckpointV4(trust, o.ArtifactRoot, refs) + if err != nil { + return CommandResult{}, err + } + if c.CeremonyID != d.CeremonyID { + return CommandResult{}, errors.New("authenticated ceremony changed during checkpoint inspection") + } + return CommandResult{CeremonyID: c.CeremonyID, + Summary: "Authenticated checkpoint ancestry and legal metadata transitions. Referenced artifacts, contribution mathematics and global freshness were not verified.", + CheckpointInspectionV4: &CheckpointInspectionV4{Schema: "proof-tool-mpc-checkpoint-inspection-v4", Depth: "checkpoint-structure", Checkpoint: c, CheckpointRefs: refs, Commitments: commitments}}, nil + } + if command != CommandCheckpointPrepareV4 && command != CommandCheckpointSignV4 { + return CommandResult{}, errors.New("unknown V4 checkpoint command") + } + data, err := readRegularOperationalFile(o.ProposalPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + var proposal m.CheckpointV4 + if err := m.UnmarshalCanonical(data, &proposal); err != nil { + return CommandResult{}, err + } + if err := proposal.Validate(); err != nil { + return CommandResult{}, err + } + if proposal.CeremonyID != d.CeremonyID { + return CommandResult{}, errors.New("proposal belongs to another ceremony") + } + if (proposal.Transition.Kind == m.CheckpointContributionRejected) != (o.RejectedCandidateDir != "") { + return CommandResult{}, errors.New("only contribution-rejected requires --rejected-candidate-dir") + } + if err := validateCheckpointPathsV4(o); err != nil { + return CommandResult{}, err + } + needsCircuit, err := checkpointNeedsCircuitV4(proposal.Transition.Kind) + if err != nil { + return CommandResult{}, err + } + var circuit *m.CompiledCircuit + if needsCircuit { + // Authenticate the stored circuit bytes; never rebuild a possibly different circuit. + path := filepath.Join(o.ArtifactRoot, filepath.FromSlash(d.Circuit.R1CS.Name)) + ref, err := checkpointArtifactRef(o.ArtifactRoot, path) + if err != nil { + return CommandResult{}, err + } + if ref != d.Circuit.R1CS { + return CommandResult{}, errors.New("stored circuit differs from the signed definition") + } + circuit, err = m.ReadR1CSFile(path, d.Circuit) + if err != nil { + return CommandResult{}, err + } + } + checked, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{ + Trust: trust, ArtifactRoot: o.ArtifactRoot, Proposal: proposal, Circuit: circuit, + RejectedCandidateDir: o.RejectedCandidateDir, + RequireCurrentReplayExecutable: command == CommandCheckpointSignV4, + }) + if err != nil { + return CommandResult{}, err + } + if !bytes.Equal(data, checked) { + return CommandResult{}, errors.New("checked checkpoint differs from exact proposal bytes") + } + summary := "Checked this exact local proposal; it is unsigned and is not published ceremony state." + outputKind := "proposal" + if command == CommandCheckpointSignV4 { + private, public, err := keybundle.LoadExistingPrivateKey(o.CoordinatorSigningKey) + if err != nil { + return CommandResult{}, err + } + if !bytes.Equal(public, trusted.CoordinatorPublicKey) { + return CommandResult{}, errors.New("checkpoint signing key is not the authenticated coordinator key") + } + signature, err := m.SignExact(checked, d.Coordinator.KeyID, private) + if err != nil { + return CommandResult{}, err + } + checked, err = m.MarshalCanonical(signature) + if err != nil { + return CommandResult{}, err + } + summary = "Signed this exact proposal. It is not the published current head until the delivery service uploads the pair and successfully updates the head." + outputKind = "checkpoint_signature" + } + if err := writeFreshOperationalFile(o.OutPath, checked, 0o600); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: d.CeremonyID, Summary: summary, Outputs: map[string]string{outputKind: o.OutPath, "input_proposal": o.ProposalPath}}, nil +} + +func loadCheckpointCircuitV4(root string, d m.CeremonyDefinition) (*m.CompiledCircuit, error) { + path := filepath.Join(root, filepath.FromSlash(d.Circuit.R1CS.Name)) + ref, err := checkpointArtifactRef(root, path) + if err != nil { + return nil, err + } + if ref != d.Circuit.R1CS { + return nil, errors.New("stored circuit differs from the signed definition") + } + return m.ReadR1CSFile(path, d.Circuit) +} + +func validateCheckpointPathsV4(o CheckpointOptionsV4) error { + for _, path := range []string{o.ProposalPath, o.OutPath} { + for _, subtree := range []string{"final/candidate", "final/release"} { + if err := validatePathOutsideTree(o.ArtifactRoot, subtree, path); err != nil { + return err + } + } + if o.RejectedCandidateDir != "" { + if err := validatePathOutsideTree(o.RejectedCandidateDir, "", path); err != nil { + return err + } + } + } + if o.RejectedCandidateDir != "" { + // Do not stage private rejected bytes anywhere under the public artifact + // root, or make that root a child of the private candidate directory. + public, err := filepath.EvalSymlinks(o.ArtifactRoot) + if err != nil { + return err + } + private, err := filepath.EvalSymlinks(o.RejectedCandidateDir) + if err != nil { + return err + } + if err := validatePathOutsideTree(public, "", private); err != nil { + return errors.New("private rejected candidate and public artifact root must be disjoint") + } + if err := validatePathOutsideTree(private, "", public); err != nil { + return errors.New("private rejected candidate and public artifact root must be disjoint") + } + } + return nil +} + +func validateCheckpointAtomicOutputV4(o CheckpointOptionsV4) error { + for _, subtree := range []string{"final/candidate", "final/release"} { + if err := validatePathOutsideTree(o.ArtifactRoot, subtree, o.OutDir); err != nil { + return err + } + } + if o.CandidateDir != "" { + if err := validatePathOutsideTree(o.CandidateDir, "", o.OutDir); err != nil { + return errors.New("checkpoint output must stay outside the fixed candidate directory") + } + } + if o.RejectedCandidateDir != "" { + public, err := filepath.EvalSymlinks(o.ArtifactRoot) + if err != nil { + return err + } + private, err := filepath.EvalSymlinks(o.RejectedCandidateDir) + if err != nil { + return err + } + if err := validatePathOutsideTree(public, "", private); err != nil { + return errors.New("private rejected candidate and public artifact root must be disjoint") + } + if err := validatePathOutsideTree(private, "", public); err != nil { + return errors.New("private rejected candidate and public artifact root must be disjoint") + } + if err := validatePathOutsideTree(private, "", o.OutDir); err != nil { + return errors.New("checkpoint output must stay outside the private rejected candidate directory") + } + } + return nil +} diff --git a/cmd/mpc-ceremony/checkpoint_v4_parse_test.go b/cmd/mpc-ceremony/checkpoint_v4_parse_test.go new file mode 100644 index 00000000..06f08e87 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_v4_parse_test.go @@ -0,0 +1,35 @@ +package main + +import "testing" + +func TestParseCheckpointInitializeV4RequiresOnlyAuthenticatedGenesisInputs(t *testing.T) { + o, err := parseCheckpointV4("initialize-v4", []string{ + "--ceremony", "/public/ceremony.json", + "--ceremony-signature", "/public/ceremony.sig", + "--coordinator-public-key-file", "/trust/coordinator.hex", + "--artifact-root", "/public", + "--coordinator-signing-key", "/keys/signing.hex", + "--out-dir", "/public/checkpoints/initial", + }) + if err != nil { + t.Fatal(err) + } + if o.CheckpointPath != "" || o.ProposalPath != "" || o.OutDir != "/public/checkpoints/initial" { + t.Fatalf("initialization accepted caller-authored state: %+v", o) + } +} + +func TestParseCheckpointInitializeV4RejectsPredecessor(t *testing.T) { + _, err := parseCheckpointV4("initialize-v4", []string{ + "--ceremony", "/public/ceremony.json", + "--ceremony-signature", "/public/ceremony.sig", + "--coordinator-public-key-file", "/trust/coordinator.hex", + "--artifact-root", "/public", + "--coordinator-signing-key", "/keys/signing.hex", + "--out-dir", "/public/checkpoints/initial", + "--checkpoint", "/public/checkpoints/another.json", + }) + if err == nil { + t.Fatal("initialization accepted a caller-supplied predecessor") + } +} diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go new file mode 100644 index 00000000..cac0b319 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -0,0 +1,337 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "os" + "os/exec" + "path/filepath" + "runtime" + "slices" + "strings" + "testing" + + m "proof-tool/internal/mpcceremony" +) + +func TestCheckpointV4CircuitClassification(t *testing.T) { + for _, tc := range []struct { + math bool + kinds []m.CheckpointTransitionKind + }{ + {true, []m.CheckpointTransitionKind{m.CheckpointInitial, m.CheckpointPhase1CandidateAccepted, m.CheckpointPhase2CandidateAccepted, m.CheckpointPhase1Sealed, m.CheckpointPhase2Initialized, m.CheckpointFinalCandidateRecorded}}, + {false, []m.CheckpointTransitionKind{m.CheckpointPhase1CandidateAllocated, m.CheckpointPhase2CandidateAllocated, m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, m.CheckpointReleaseReviewRecorded, m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, m.CheckpointWitnessRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted}}, + } { + for _, kind := range tc.kinds { + if got, err := checkpointNeedsCircuitV4(kind); err != nil || got != tc.math { + t.Fatalf("%s: %v %v", kind, got, err) + } + } + } + if _, err := checkpointNeedsCircuitV4("future-transition"); err == nil { + t.Fatal("unknown transition silently skips circuit verification") + } +} + +func TestCheckpointRejectCandidateV4ParserRequiresPrivateCandidate(t *testing.T) { + root := t.TempDir() + args := []string{"checkpoint", "reject-candidate-v4", + "--ceremony", filepath.Join(root, "ceremony.json"), "--ceremony-signature", filepath.Join(root, "ceremony.sig"), "--coordinator-public-key-file", filepath.Join(root, "coordinator.hex"), + "--artifact-root", root, "--checkpoint", filepath.Join(root, "checkpoint.json"), "--checkpoint-signature", filepath.Join(root, "checkpoint.sig"), + "--attempt-id", strings.Repeat("a", 32), "--coordinator-signing-key", filepath.Join(root, "private.hex"), "--out-dir", filepath.Join(root, "out"), + } + if _, err := parseInvocation(args); err == nil || !strings.Contains(err.Error(), "--rejected-candidate-dir") { + t.Fatalf("missing private candidate accepted: %v", err) + } + args = append(args, "--rejected-candidate-dir", filepath.Join(root, "private")) + invocation, err := parseInvocation(args) + if err != nil || invocation.Command != CommandCheckpointRejectCandidateV4 { + t.Fatalf("direct rejection did not parse: %q %v", invocation.Command, err) + } +} + +func TestCheckpointV4ClosedTreesAndDiagnosticGrammar(t *testing.T) { + root, private := t.TempDir(), t.TempDir() + for _, name := range []string{"final/candidate", "final/release", "checkpoints"} { + if err := os.MkdirAll(filepath.Join(root, name), 0o700); err != nil { + t.Fatal(err) + } + } + for name, target := range map[string]string{"candidate-alias": filepath.Join(root, "final/candidate"), "release-alias": filepath.Join(root, "final/release"), "private-alias": private} { + if err := os.Symlink(target, filepath.Join(root, name)); err != nil { + t.Fatal(err) + } + } + o := CheckpointOptionsV4{ArtifactRoot: root, ProposalPath: filepath.Join(root, "proposal.json"), OutPath: filepath.Join(root, "checkpoints/new.sig"), RejectedCandidateDir: private} + if err := validateCheckpointPathsV4(o); err != nil { + t.Fatal(err) + } + for _, path := range []string{filepath.Join(root, "final/candidate/new"), filepath.Join(root, "final/release/new"), filepath.Join(private, "new"), filepath.Join(root, "candidate-alias/new"), filepath.Join(root, "release-alias/new"), filepath.Join(root, "private-alias/new")} { + for _, proposal := range []bool{false, true} { + bad := o + if proposal { + bad.ProposalPath = path + } else { + bad.OutPath = path + } + if err := validateCheckpointPathsV4(bad); err == nil { + t.Fatalf("closed-tree path accepted: %s", path) + } + } + } + bad := o + bad.RejectedCandidateDir = filepath.Join(root, "checkpoints") + if err := validateCheckpointPathsV4(bad); err == nil { + t.Fatal("private candidate allowed in public root") + } + for _, action := range []string{"prepare-v4", "sign-v4", "verify-stored-v4"} { + args := []string{"checkpoint", action, "--proposal", "/private/operator/path"} + message := redactCLIError("checkpoint "+action+" --proposal /private/operator/path", args) + if !strings.Contains(message, action) || !strings.Contains(message, "--proposal") || strings.Contains(message, "/private/operator/path") { + t.Fatalf("grammar/privacy: %s", message) + } + } +} + +func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("approved executable identity is tested in Linux Docker") + } + root := t.TempDir() + executable := filepath.Join(root, "mpc-ceremony") + if out, err := exec.Command("go", "build", "-o", executable, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, out) + } + d, _, key := decisionSignFixture(t) + d.AssurancePolicy.ExternalSecurityAuditSignoffs = 0 + writeJSON := func(path string, v any) []byte { + t.Helper() + b, err := m.MarshalCanonical(v) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, path, b, 0o600) + return b + } + participants := filepath.Join(root, "participants.json") + policy := filepath.Join(root, "policy.json") + keyPath := filepath.Join(root, "coordinator-private.hex") + writeJSON(participants, m.InitParticipants{Coordinator: d.Coordinator, ReleaseSigner: d.ReleaseSigner, Auditors: d.Auditors, Roster: d.Roster}) + writeJSON(policy, m.InitPolicy{Phase1Policy: d.Phase1Policy, Phase2Policy: d.Phase2Policy, BeaconPolicy: d.BeaconPolicy, AssurancePolicy: d.AssurancePolicy}) + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(key)), 0o600) + initArgs := []string{"--format", "json", "init", "--mode", "rehearsal", "--key-version", "rehearsal-tiny-v1", "--participants", participants, "--policy", policy, + "--coordinator-key-id", d.Coordinator.KeyID, "--coordinator-signing-key", keyPath, "--created-at", "2026-09-16T00:00:00Z"} + legacyRoot := filepath.Join(root, "legacy") + legacy := runCheckpointCommandExecutable(t, executable, append(append([]string{}, initArgs...), "--out-dir", legacyRoot)) + var legacyDefinition m.CeremonyDefinition + if err := m.UnmarshalCanonical(mustReadTestFile(t, legacy.Outputs["ceremony"]), &legacyDefinition); err != nil { + t.Fatal(err) + } + if legacyDefinition.Schema != m.DefinitionSchemaV3 || legacyDefinition.ReleaseVerification != "" { + t.Fatal("default init changed released semantics") + } + artifactRoot := filepath.Join(root, "v4") + created := runCheckpointCommandExecutable(t, executable, append(append([]string{}, initArgs...), "--out-dir", artifactRoot, "--release-verification", m.CoordinatorReplayReleaseV1)) + d = m.CeremonyDefinition{} + if err := m.UnmarshalCanonical(mustReadTestFile(t, created.Outputs["ceremony"]), &d); err != nil { + t.Fatal(err) + } + if d.Schema != m.DefinitionSchemaV4 { + t.Fatal("explicit V4 option did not bind new schema") + } + assertCheckpointExecutableFails(t, executable, append(append([]string{}, initArgs...), "--out-dir", filepath.Join(root, "invalid"), "--release-verification", "skip-checks"), "must be coordinator-full-replay-v1") + ref := func(name string) m.ArtifactRef { + return m.ArtifactRef{Name: name, Digest: m.NewDigest(mustReadTestFile(t, filepath.Join(artifactRoot, name)))} + } + pair := func(name string) m.SignedArtifactRefs { + return m.SignedArtifactRefs{Record: ref(name + ".json"), Signature: ref(name + ".sig")} + } + definition, chainRefs := pair("ceremony"), pair("phase1/chain-0000") + var chain m.Chain + if err := m.UnmarshalCanonical(mustReadTestFile(t, filepath.Join(artifactRoot, chainRefs.Record.Name)), &chain); err != nil { + t.Fatal(err) + } + checkContributionInventoryExecutableV4(t, executable, artifactRoot, d, chain) + head, err := chain.HeadRecordID() + if err != nil { + t.Fatal(err) + } + payload, err := chain.HeadPayload() + if err != nil { + t.Fatal(err) + } + refs := []m.ArtifactRef{definition.Record, definition.Signature, d.Circuit.R1CS, chainRefs.Record, chainRefs.Signature, payload} + slices.SortFunc(refs, func(a, b m.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + proposal := m.CheckpointV4{Schema: m.CheckpointSchemaV4, Workflow: m.StorageFirstWorkflowV2, CeremonyID: d.CeremonyID, Definition: definition, AssurancePolicy: d.AssurancePolicy, ReleaseVerification: m.CoordinatorReplayReleaseV1, + Transition: m.CheckpointTransitionV4{Kind: m.CheckpointInitial, Evidence: []m.ArtifactRef{}}, Progress: m.CheckpointProgressV4{Phase1: m.CheckpointPhaseState{Phase: m.Phase1, HeadRecordID: head, HeadPayload: payload, Chain: chainRefs}}, AcceptedArtifacts: refs, Deliveries: []m.DeliverySlotV2{}} + proposalPath, checkedPath, signaturePath := filepath.Join(artifactRoot, "proposal.json"), filepath.Join(artifactRoot, "checked.json"), filepath.Join(artifactRoot, "checked.sig") + data := writeJSON(proposalPath, proposal) + trustArgs := []string{"--ceremony", created.Outputs["ceremony"], "--ceremony-signature", created.Outputs["ceremony_signature"], "--coordinator-public-key-file", created.Outputs["coordinator_public_key"], "--artifact-root", artifactRoot} + initialDir := filepath.Join(artifactRoot, "checkpoints", "initial") + if err := os.MkdirAll(filepath.Dir(initialDir), 0o700); err != nil { + t.Fatal(err) + } + initialize := append([]string{"--format", "json", "checkpoint", "initialize-v4"}, trustArgs...) + initialize = append(initialize, "--coordinator-signing-key", keyPath, "--out-dir", initialDir) + initialized := runCheckpointCommandExecutable(t, executable, initialize) + if !bytes.Equal(data, mustReadTestFile(t, initialized.Outputs["checkpoint"])) || initialized.Sequence != 0 { + t.Fatal("initialize-v4 did not derive the exact only valid initial checkpoint") + } + prepare := append([]string{"--format", "json", "checkpoint", "prepare-v4"}, trustArgs...) + prepare = append(prepare, "--proposal", proposalPath, "--out", checkedPath) + runCheckpointCommandExecutable(t, executable, prepare) + if !bytes.Equal(data, mustReadTestFile(t, checkedPath)) { + t.Fatal("prepare normalized exact proposal") + } + sign := append([]string{"--format", "json", "checkpoint", "sign-v4"}, trustArgs...) + sign = append(sign, "--proposal", checkedPath, "--coordinator-signing-key", keyPath, "--out", signaturePath) + signed := runCheckpointCommandExecutable(t, executable, sign) + if !strings.Contains(signed.Summary, "not the published current head") { + t.Fatal("signature implies publication") + } + inspect := append([]string{"--format", "json", "checkpoint", "verify-stored-v4"}, trustArgs...) + inspect = append(inspect, "--checkpoint", checkedPath, "--checkpoint-signature", signaturePath) + result := runCheckpointCommandExecutable(t, executable, inspect) + projection := result.CheckpointInspectionV4 + if projection == nil || projection.Depth != "checkpoint-structure" || projection.ArtifactsVerified || projection.MathematicsReplayed || projection.GlobalFreshnessVerified { + t.Fatalf("overclaim: %+v", projection) + } + assertCheckpointExecutableFails(t, executable, sign, "fresh operational artifact") + discoverArgs := append([]string{"--format", "json", "checkpoint", "inspect-signed-v4"}, trustArgs...) + discoverArgs = append(discoverArgs, "--checkpoint", checkedPath, "--checkpoint-signature", signaturePath) + discovered := runCheckpointCommandExecutable(t, executable, discoverArgs).CheckpointDiscoveryV4 + if discovered == nil || discovered.Schema != "proof-tool-mpc-checkpoint-discovery-v4" || discovered.Depth != "signed-checkpoint-discovery" || discovered.AncestryVerified || discovered.ArtifactsVerified || discovered.MathematicsReplayed || discovered.GlobalFreshnessVerified || discovered.Discovery.Sequence != 0 || len(discovered.Discovery.VerificationDependencies) != 0 || discovered.CheckpointRefs != projection.CheckpointRefs { + t.Fatalf("discovery overclaim or wrong head: %+v", discovered) + } + enrollmentArgs := append([]string{"--format", "json", "checkpoint", "inspect-enrollments-v4"}, trustArgs...) + enrollmentArgs = append(enrollmentArgs, "--checkpoint", checkedPath, "--checkpoint-signature", signaturePath) + enrollments := runCheckpointCommandExecutable(t, executable, enrollmentArgs).EnrollmentMetadataV4 + if enrollments == nil || enrollments.Schema != "proof-tool-mpc-enrollment-metadata-v4" || enrollments.Depth != "committed-enrollment-signatures" || !enrollments.EnrollmentSignaturesVerified || enrollments.DisclosureContentsVerified || enrollments.CompleteRosterVerified || enrollments.GlobalFreshnessVerified || enrollments.Metadata.Checkpoint != projection.CheckpointRefs || len(enrollments.Metadata.Enrollments) != 0 { + t.Fatalf("empty enrollment set overclaim or wrong head: %+v", enrollments) + } + disclosurePath := filepath.Join(artifactRoot, "enrollments", "participant-01", "disclosure.txt") + if err := os.MkdirAll(filepath.Dir(disclosurePath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, disclosurePath, []byte("Single-process CLI fixture; no independence claim.\n"), 0o600) + disclosure := ref("enrollments/participant-01/disclosure.txt") + participant := d.Roster[0].Identity + enrollment, err := m.NewEnrollmentRecord(d, mustReadTestFile(t, created.Outputs["ceremony"]), participant, m.EnrollmentParticipant, 1, disclosure, "2026-09-16T00:00:01Z") + if err != nil { + t.Fatal(err) + } + enrollmentBytes, enrollmentSignature, err := m.SignRecord(enrollment, participant.KeyID, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize))) + if err != nil { + t.Fatal(err) + } + enrollmentPath := filepath.Join(artifactRoot, "enrollments", "participant-01", "record.json") + enrollmentSignaturePath := filepath.Join(artifactRoot, "enrollments", "participant-01", "record.sig") + writeDecisionTestFile(t, enrollmentPath, enrollmentBytes, 0o600) + writeDecisionTestFile(t, enrollmentSignaturePath, enrollmentSignature, 0o600) + recordedDir := filepath.Join(artifactRoot, "checkpoints", "participant-enrollment") + record := append([]string{"--format", "json", "checkpoint", "record-v4"}, trustArgs...) + record = append(record, + "--checkpoint", initialized.Outputs["checkpoint"], "--checkpoint-signature", initialized.Outputs["checkpoint_signature"], + "--transition", string(m.CheckpointEnrollmentRecorded), "--record", enrollmentPath, "--record-signature", enrollmentSignaturePath, + "--evidence", disclosurePath, "--coordinator-signing-key", keyPath, "--out-dir", recordedDir, + ) + recorded := runCheckpointCommandExecutable(t, executable, record) + if recorded.Sequence != 1 { + t.Fatalf("record-v4 sequence = %d, want 1", recorded.Sequence) + } + recordedInspect := append([]string{"--format", "json", "checkpoint", "verify-stored-v4"}, trustArgs...) + recordedInspect = append(recordedInspect, "--checkpoint", recorded.Outputs["checkpoint"], "--checkpoint-signature", recorded.Outputs["checkpoint_signature"]) + if got := runCheckpointCommandExecutable(t, executable, recordedInspect).CheckpointInspectionV4; got == nil || got.Checkpoint.Transition.Kind != m.CheckpointEnrollmentRecorded { + t.Fatalf("recorded enrollment did not authenticate: %+v", got) + } + // A rejection intentionally records opaque candidate bytes. The dummy files + // below are not valid records or signatures; the direct rejection command + // must still bind their exact fixed five-file inventory to the active turn. + attemptID := strings.Repeat("a", 32) + allocationDir := filepath.Join(artifactRoot, "checkpoints", "allocation") + allocate := append([]string{"--format", "json", "checkpoint", "allocate-v4"}, trustArgs...) + allocate = append(allocate, + "--checkpoint", recorded.Outputs["checkpoint"], "--checkpoint-signature", recorded.Outputs["checkpoint_signature"], + "--attempt-id", attemptID, "--allocated-at", "2026-09-16T00:00:01Z", "--coordinator-signing-key", keyPath, "--out-dir", allocationDir, + ) + allocated := runCheckpointCommandExecutable(t, executable, allocate) + privateCandidate := filepath.Join(root, "private-rejected-candidate") + if err := os.MkdirAll(privateCandidate, 0o700); err != nil { + t.Fatal(err) + } + for name, contents := range map[string]string{ + "attestation.json": "intentionally invalid attestation", "attestation.sig": "invalid signature", "contribution.bin": "unverified candidate bytes", "erasure.json": "intentionally invalid cleanup", "erasure.sig": "invalid signature", + } { + writeDecisionTestFile(t, filepath.Join(privateCandidate, name), []byte(contents), 0o600) + } + rejectionDir := filepath.Join(artifactRoot, "checkpoints", "rejected") + reject := append([]string{"--format", "json", "checkpoint", "reject-candidate-v4"}, trustArgs...) + reject = append(reject, + "--checkpoint", allocated.Outputs["checkpoint"], "--checkpoint-signature", allocated.Outputs["checkpoint_signature"], + "--attempt-id", attemptID, "--rejected-candidate-dir", privateCandidate, "--coordinator-signing-key", keyPath, "--out-dir", rejectionDir, + ) + rejected := runCheckpointCommandExecutable(t, executable, reject) + if rejected.Sequence != 3 || !strings.Contains(rejected.Summary, "fresh contribution") { + t.Fatalf("unexpected rejection result: %+v", rejected) + } + var rejectedCheckpoint m.CheckpointV4 + if err := m.UnmarshalCanonical(mustReadTestFile(t, rejected.Outputs["checkpoint"]), &rejectedCheckpoint); err != nil { + t.Fatal(err) + } + if rejectedCheckpoint.Transition.Kind != m.CheckpointContributionRejected || rejectedCheckpoint.Transition.AttemptID != attemptID || rejectedCheckpoint.Transition.NextAttemptID != "" || rejectedCheckpoint.Transition.Contribution == nil || len(rejectedCheckpoint.Transition.Contribution.Files) != 5 { + t.Fatalf("direct rejection did not retain the exact terminal allocation: %+v", rejectedCheckpoint.Transition) + } + // Repeating the exact operation from the same authenticated parent is + // idempotent: it must reproduce the same signed child, not invent another + // transition. A caller that has advanced to the rejection checkpoint will + // observe that the allocation is no longer active there. + rejectedAgain := runCheckpointCommandExecutable(t, executable, append(reject[:len(reject)-2], "--out-dir", filepath.Join(artifactRoot, "checkpoints", "rejected-again"))) + for _, name := range []string{"checkpoint", "checkpoint_signature"} { + if !bytes.Equal(mustReadTestFile(t, rejected.Outputs[name]), mustReadTestFile(t, rejectedAgain.Outputs[name])) { + t.Fatalf("exact rejection replay changed %s bytes", name) + } + } + // Sign again only after rereading every required byte, not a saved success marker. + genesis := filepath.Join(artifactRoot, payload.Name) + original := mustReadTestFile(t, genesis) + bad := bytes.Clone(original) + bad[0] ^= 1 + writeDecisionTestFile(t, genesis, bad, 0o600) + failedOutput := filepath.Join(artifactRoot, "must-not-exist.sig") + badSign := append([]string{"checkpoint", "sign-v4"}, trustArgs...) + badSign = append(badSign, "--proposal", checkedPath, "--coordinator-signing-key", filepath.Join(root, "MISSING-KEY"), "--out", failedOutput) + out, err := exec.Command(executable, badSign...).CombinedOutput() + if err == nil || !strings.Contains(string(out), "differs from its exact committed bytes") { + t.Fatalf("mutation not rejected before key: %v %s", err, out) + } + if _, err := os.Stat(failedOutput); !os.IsNotExist(err) { + t.Fatalf("failed signing wrote output: %v", err) + } + // Structural sync is intentionally narrower and does not reread genesis. + runCheckpointCommandExecutable(t, executable, inspect) + writeDecisionTestFile(t, genesis, original, 0o600) + wrongKey := filepath.Join(root, "other-private.hex") + writeDecisionTestFile(t, wrongKey, []byte(hex.EncodeToString(ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)))), 0o600) + wrongSign := append([]string{"checkpoint", "sign-v4"}, trustArgs...) + wrongSign = append(wrongSign, "--proposal", checkedPath, "--coordinator-signing-key", wrongKey, "--out", failedOutput) + assertCheckpointExecutableFails(t, executable, wrongSign, "not the authenticated coordinator key") + assertCheckpointExecutableFails(t, executable, append(append([]string{}, badSign...), "--rejected-candidate-dir", root), "only contribution-rejected requires") + oldTrust := append([]string{}, trustArgs...) + oldTrust[1], oldTrust[3] = legacy.Outputs["ceremony"], legacy.Outputs["ceremony_signature"] + oldInspect := append([]string{"checkpoint", "verify-stored-v4"}, oldTrust...) + oldInspect = append(oldInspect, "--checkpoint", checkedPath, "--checkpoint-signature", signaturePath) + assertCheckpointExecutableFails(t, executable, oldInspect, "require definition v4") + legacyInspect := append([]string{"checkpoint", "verify-stored"}, trustArgs...) + legacyInspect = append(legacyInspect, "--checkpoint", checkedPath, "--checkpoint-signature", signaturePath) + if out, err := exec.Command(executable, legacyInspect...).CombinedOutput(); err == nil { + t.Fatalf("legacy verifier accepted V4: %s", out) + } + for _, badData := range [][]byte{append(bytes.Clone(data), '\n'), bytes.Replace(data, []byte(`"schema":`), []byte(`"unknown":true,"schema":`), 1)} { + writeDecisionTestFile(t, checkedPath, badData, 0o600) + out, err := exec.Command(executable, badSign...).CombinedOutput() + if err == nil || !strings.Contains(string(out), "canonical") { + t.Fatalf("bad proposal reached key: %v %s", err, out) + } + } +} diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index b9047149..863dc461 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -1076,6 +1076,43 @@ func TestRunCLIRejectsResultOutputFailure(t *testing.T) { } } +type candidateInvalidTestError struct{} + +func (candidateInvalidTestError) Error() string { return "candidate semantic validation failed" } +func (candidateInvalidTestError) CandidateInvalid() {} + +func TestWriteExecutionErrorJSONClassifiesCandidateInvalidOnly(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + err error + want string + }{ + {name: "semantic candidate failure", err: candidateInvalidTestError{}, want: "candidate_invalid"}, + {name: "operational failure", err: errors.New("candidate file missing"), want: "internal_error"}, + } { + t.Run(tc.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + exitCode := writeExecutionError(Invocation{Global: GlobalOptions{Format: "json"}, Command: CommandInspectContributionInventoryV4}, tc.err, []string{"--format=json", "inspect", "contribution-inventory-v4"}, &stdout, &stderr) + if exitCode != 6 || stderr.Len() != 0 { + t.Fatalf("exit/stderr = %d/%q", exitCode, stderr.String()) + } + var result struct { + Error struct { + Code string `json:"code"` + } `json:"error"` + } + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode JSON error = %v; stdout = %q", err, stdout.String()) + } + if result.Error.Code != tc.want { + t.Fatalf("error code = %q, want %q", result.Error.Code, tc.want) + } + }) + } +} + type failingWriter struct{} func (failingWriter) Write([]byte) (int, error) { diff --git a/cmd/mpc-ceremony/computation_output_v4.go b/cmd/mpc-ceremony/computation_output_v4.go new file mode 100644 index 00000000..9094b700 --- /dev/null +++ b/cmd/mpc-ceremony/computation_output_v4.go @@ -0,0 +1,27 @@ +package main + +import m "proof-tool/internal/mpcceremony" + +type ComputationOutputInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Output m.ComputationOutputInspectionV4 `json:"output"` + SignaturesVerified bool `json:"signatures_verified"` + PayloadDigestVerified bool `json:"payload_digest_verified"` + CleanupVerified bool `json:"cleanup_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` + PhysicalErasureVerified bool `json:"physical_erasure_verified"` +} + +func executeComputationOutputV4(o ContributionInventoryOptionsV4) (CommandResult, error) { + scope, err := expectedContributionInspectionScopeV4(o) + if err != nil { + return CommandResult{}, err + } + output, err := m.InspectComputationOutputV4(trustPaths(o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile), m.PhaseTranscriptPaths{RootDir: o.TranscriptRoot, ChainPath: o.ChainPath, ChainSignaturePath: o.ChainSignaturePath}, scope, o.CandidateDir) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: scope.CeremonyID, Phase: string(scope.Phase), Summary: "Verified the three generated public files. Cleanup, process exit, mathematics, freshness and acceptance are not verified.", ComputationOutputV4: &ComputationOutputInspectionV4{Schema: "proof-tool-mpc-computation-output-inspection-v4", Depth: "computation-signatures-and-digests", Output: output, SignaturesVerified: true, PayloadDigestVerified: true}}, nil +} diff --git a/cmd/mpc-ceremony/contribution_inventory_v4.go b/cmd/mpc-ceremony/contribution_inventory_v4.go new file mode 100644 index 00000000..83b0b49c --- /dev/null +++ b/cmd/mpc-ceremony/contribution_inventory_v4.go @@ -0,0 +1,73 @@ +package main + +import m "proof-tool/internal/mpcceremony" + +type ContributionInventoryOptionsV4 struct { + InspectChainOptions + ScopePath string + CandidateDir string +} + +type ContributionInventoryInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Inventory m.ContributionInventoryInspectionV4 `json:"inventory"` + SignaturesVerified bool `json:"signatures_verified"` + PayloadDigestVerified bool `json:"payload_digest_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` + PhysicalErasureVerified bool `json:"physical_erasure_verified"` +} + +func parseContributionInventoryV4(args []string) (ContributionInventoryOptionsV4, error) { + return parseContributionInspectionV4("contribution-inventory-v4", args) +} + +func parseContributionInspectionV4(name string, args []string) (ContributionInventoryOptionsV4, error) { + var o ContributionInventoryOptionsV4 + fs := commandFlagSet("inspect " + name) + addCeremonyTrustFlags(fs, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + fs.StringVar(&o.TranscriptRoot, "transcript-root", "", "local transcript root") + fs.StringVar(&o.ChainPath, "chain", "", "exact signed predecessor chain") + fs.StringVar(&o.ChainSignaturePath, "chain-signature", "", "predecessor signature") + fs.StringVar(&o.ScopePath, "scope", "", "canonical expected contribution scope from authenticated state or retained operation") + fs.StringVar(&o.CandidateDir, "candidate-dir", "", "retained candidate directory; fixed public filenames only") + if err := parseFlags(fs, args); err != nil { + return o, err + } + return o, requireValues(pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--transcript-root", o.TranscriptRoot), pathValue("--chain", o.ChainPath), pathValue("--chain-signature", o.ChainSignaturePath), pathValue("--scope", o.ScopePath), pathValue("--candidate-dir", o.CandidateDir)) +} + +func executeContributionInventoryV4(o ContributionInventoryOptionsV4) (CommandResult, error) { + scope, err := expectedContributionInspectionScopeV4(o) + if err != nil { + return CommandResult{}, err + } + i, err := m.InspectContributionInventoryV4(trustPaths(o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile), m.PhaseTranscriptPaths{RootDir: o.TranscriptRoot, ChainPath: o.ChainPath, ChainSignaturePath: o.ChainSignaturePath}, scope, o.CandidateDir) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: i.Scope.CeremonyID, Phase: string(i.Scope.Phase), Summary: "Verified retained candidate signatures, exact file digests and expected predecessor. No contribution mathematics, acceptance, freshness or physical erasure verified.", ContributionInventoryV4: &ContributionInventoryInspectionV4{Schema: "proof-tool-mpc-contribution-inventory-inspection-v4", Depth: "candidate-signatures-and-digests", Inventory: i, SignaturesVerified: true, PayloadDigestVerified: true}}, nil +} + +func expectedContributionInspectionScopeV4(o ContributionInventoryOptionsV4) (m.ContributionScope, error) { + var scope m.ContributionScope + trusted, err := loadInspectionCeremony(o.InspectDefinitionOptions) + if err != nil { + return scope, err + } + if err := m.VerifyRunningSoftwareForMode(trusted.Definition.Software, trusted.Definition.Mode); err != nil { + return scope, err + } + raw, err := readRegularOperationalFile(o.ScopePath, 4096) + if err != nil { + return scope, err + } + if err := m.UnmarshalCanonical(raw, &scope); err != nil { + return scope, err + } + if err := scope.ValidateAssignment(trusted.Definition); err != nil { + return scope, err + } + return scope, nil +} diff --git a/cmd/mpc-ceremony/contribution_inventory_v4_test.go b/cmd/mpc-ceremony/contribution_inventory_v4_test.go new file mode 100644 index 00000000..26cb1e7a --- /dev/null +++ b/cmd/mpc-ceremony/contribution_inventory_v4_test.go @@ -0,0 +1,120 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + m "proof-tool/internal/mpcceremony" +) + +func TestContributionInventoryV4CommandSurface(t *testing.T) { + args := []string{"--ceremony", "ceremony.json", "--ceremony-signature", "ceremony.sig", "--coordinator-public-key-file", "coordinator.hex", "--transcript-root", "transcript", "--chain", "transcript/phase1/chain-0000.json", "--chain-signature", "transcript/phase1/chain-0000.sig", "--scope", "scope.json", "--candidate-dir", "candidate"} + o, err := parseContributionInventoryV4(args) + if err != nil || o.ScopePath != "scope.json" || o.CandidateDir != "candidate" { + t.Fatal(o, err) + } + for n := 0; n < len(args); n += 2 { + missing := append(append([]string{}, args[:n]...), args[n+2:]...) + if _, err := parseContributionInventoryV4(missing); err == nil { + t.Fatalf("accepted missing %s", args[n]) + } + } + for _, secret := range []string{"--participant-signing-key", "--coordinator-signing-key", "--signing-key"} { + if _, err := parseContributionInventoryV4(append(append([]string{}, args...), secret, "private.hex")); err == nil { + t.Fatal("read-only inspection accepted secret input", secret) + } + } + invocation, err := parseInvocation(append([]string{"--format", "json", "inspect", "contribution-inventory-v4"}, args...)) + if err != nil || invocation.Command != CommandInspectContributionInventoryV4 || !reflect.DeepEqual(invocation.Options, o) { + t.Fatal(invocation, err) + } + if !strings.Contains(commandHelp["inspect contribution-inventory-v4"], "Does not verify mathematics") { + t.Fatal("missing narrow inspection claim") + } +} + +// Called by the Linux approved-executable test after real tiny initialization. +func checkContributionInventoryExecutableV4(t *testing.T, executable, root string, d m.CeremonyDefinition, chain m.Chain) { + t.Helper() + dir := filepath.Join(root, "inspection-candidate") + if err := os.Mkdir(dir, 0700); err != nil { + t.Fatal(err) + } + write := func(name string, b []byte) { t.Helper(); writeDecisionTestFile(t, filepath.Join(dir, name), b, 0600) } + sign := func(name string, v any, keyID string, key ed25519.PrivateKey) { + t.Helper() + b, s, err := m.SignRecord(v, keyID, key) + if err != nil { + t.Fatal(err) + } + write(name+".json", b) + write(name+".sig", s) + } + head, err := chain.HeadRecordID() + if err != nil { + t.Fatal(err) + } + previous, err := chain.HeadPayload() + if err != nil { + t.Fatal(err) + } + p := d.Roster[0].Identity + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, 32)) + payload := []byte("signature-and-digest inspection deliberately does not prove contribution mathematics") + a, err := m.NewContributionAttestation(m.ContributionAttestation{CeremonyID: d.CeremonyID, Phase: m.Phase1, PhaseID: chain.PhaseID, Index: 1, ParticipantID: p.ID, ParticipantKeyID: p.KeyID, PreviousPayload: previous, PreviousAcceptanceID: head, OutputPayload: m.ArtifactRef{Name: "phase1/contributions/0001/contribution.bin", Digest: m.NewDigest(payload)}, ToolBinary: d.Software.ToolBinary, SourceCommit: d.Software.SourceCommit, GnarkVersion: d.Software.GnarkVersion, GnarkCryptoVersion: d.Software.GnarkCryptoVersion, DrandVersion: d.Software.DrandVersion, Environment: m.ContributionEnvironment{OS: "linux", Architecture: "arm64", EntropySource: "operating-system-csprng", ContributorSwapDisabled: true, ContributorCrashDumpsDisabled: true, ContributorTelemetryDisabled: true, EphemeralEnvironment: true, EphemeralCleanupRequired: true, HostRemnantsNotExcluded: true}, ContributedAt: "2026-09-16T00:01:00Z"}) + if err != nil { + t.Fatal(err) + } + e, err := m.NewErasureAttestation(m.ErasureAttestation{CeremonyID: a.CeremonyID, Phase: a.Phase, PhaseID: a.PhaseID, Index: a.Index, ParticipantID: p.ID, ParticipantKeyID: p.KeyID, ContributionAttestationID: a.AttestationID, OutputPayload: a.OutputPayload, DestroyedAt: "2026-09-16T00:02:00Z", ProcessTerminated: true, EphemeralEnvironmentRemoved: true, NoDeliberateCopiesConfirmed: true, HostRemnantsNotExcluded: true}) + if err != nil { + t.Fatal(err) + } + write("contribution.bin", payload) + sign("attestation", a, p.KeyID, key) + scope := m.ContributionScope{CeremonyID: d.CeremonyID, Phase: m.Phase1, Index: 1, ParticipantID: p.ID, ParentHeadID: head} + b, err := m.MarshalCanonical(scope) + if err != nil { + t.Fatal(err) + } + write("scope.json", b) + args := []string{"--format", "json", "inspect", "contribution-inventory-v4", "--ceremony", filepath.Join(root, "ceremony.json"), "--ceremony-signature", filepath.Join(root, "ceremony.sig"), "--coordinator-public-key-file", filepath.Join(root, "coordinator-public-key.hex"), "--transcript-root", root, "--chain", filepath.Join(root, "phase1/chain-0000.json"), "--chain-signature", filepath.Join(root, "phase1/chain-0000.sig"), "--scope", filepath.Join(dir, "scope.json"), "--candidate-dir", dir} + generatedArgs := append([]string{}, args...) + generatedArgs[3] = "computation-output-v4" + generated := runCheckpointCommandExecutable(t, executable, generatedArgs).ComputationOutputV4 + if generated == nil || len(generated.Output.Files) != 3 || generated.CleanupVerified || generated.MathematicsReplayed || generated.PhysicalErasureVerified || generated.GlobalFreshnessVerified || !generated.SignaturesVerified || !generated.PayloadDigestVerified { + t.Fatalf("wrong preliminary CLI result %+v", generated) + } + sign("erasure", e, p.KeyID, key) + five := runCheckpointCommandExecutable(t, executable, args).ContributionInventoryV4 + if five == nil || five.Inventory.Complete == nil || five.Inventory.ComputedCandidateID == "" || five.Inventory.CandidateResultID != five.Inventory.ComputedCandidateID || five.MathematicsReplayed || five.GlobalFreshnessVerified || five.PhysicalErasureVerified || !five.SignaturesVerified || !five.PayloadDigestVerified { + t.Fatalf("wrong CLI boundary %+v", five) + } + bad := d + bad.Software.ToolBinary = m.NewDigest([]byte("unapproved executable")) + bad.Software.Binaries = append([]m.SoftwareBinary{}, d.Software.Binaries...) + for i := range bad.Software.Binaries { + bad.Software.Binaries[i].ToolBinary = bad.Software.ToolBinary + } + bad, err = m.FinalizeCeremonyDefinition(bad) + if err != nil { + t.Fatal(err) + } + sign("unapproved", bad, d.Coordinator.KeyID, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, 32))) + badArgs := append([]string{}, args...) + for i := range badArgs { + if badArgs[i] == "--ceremony" { + badArgs[i+1] = filepath.Join(dir, "unapproved.json") + } + if badArgs[i] == "--ceremony-signature" { + badArgs[i+1] = filepath.Join(dir, "unapproved.sig") + } + } + assertCheckpointExecutableFails(t, executable, badArgs, "binary") + badArgs[3] = "computation-output-v4" + assertCheckpointExecutableFails(t, executable, badArgs, "binary") +} diff --git a/cmd/mpc-ceremony/decision.go b/cmd/mpc-ceremony/decision.go index 4925e898..8bed1720 100644 --- a/cmd/mpc-ceremony/decision.go +++ b/cmd/mpc-ceremony/decision.go @@ -23,6 +23,12 @@ func executeDecisionPrepare(options DecisionPrepareOptions) (CommandResult, erro if err != nil { return CommandResult{}, err } + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return executeDecisionPrepareV4(options, trusted.Definition.CeremonyID, draftBytes) + } + if options.EvidenceRoot != "" { + return CommandResult{}, fmt.Errorf("decision prepare --evidence-root is only supported for definition v4") + } decision, decisionBytes, err := mpcceremony.PrepareProductionDecision( trusted.Definition, draftBytes, @@ -59,6 +65,9 @@ func executeDecisionSign(options DecisionSignOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return executeDecisionSignV4(options, trusted.Definition.CeremonyID, decisionBytes) + } var decision mpcceremony.ProductionDecision if err := mpcceremony.UnmarshalCanonical(decisionBytes, &decision); err != nil { return CommandResult{}, err @@ -143,12 +152,19 @@ func executeDecisionVerify(options DecisionVerifyOptions) (CommandResult, error) return CommandResult{}, err } signatures := make([][]byte, len(options.SignaturePaths)) + signatureLimit := int64(maxOperationalRecordBytes) + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + signatureLimit = 4096 // Match the V4 decision signature verifier's small-record bound. + } for index, path := range options.SignaturePaths { - signatures[index], err = readRegularOperationalFile(path, maxOperationalRecordBytes) + signatures[index], err = readRegularOperationalFile(path, signatureLimit) if err != nil { return CommandResult{}, fmt.Errorf("decision signature %d: %w", index, err) } } + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return executeDecisionVerifyV4(options, trusted.Definition.CeremonyID, decisionBytes, signatures) + } verified, err := mpcceremony.VerifyProductionDecision(mpcceremony.VerifyProductionDecisionOptions{ Definition: trusted.Definition, DecisionBytes: decisionBytes, diff --git a/cmd/mpc-ceremony/decision_v4.go b/cmd/mpc-ceremony/decision_v4.go new file mode 100644 index 00000000..92938b12 --- /dev/null +++ b/cmd/mpc-ceremony/decision_v4.go @@ -0,0 +1,137 @@ +package main + +import ( + "errors" + "fmt" + "path/filepath" + "strings" + + "proof-tool/internal/keybundle" + "proof-tool/internal/mpcceremony" +) + +func decisionTrustV4(ceremony, signature, key string) mpcceremony.TrustPaths { + return mpcceremony.TrustPaths{DefinitionPath: ceremony, DefinitionSignaturePath: signature, CoordinatorPublicKeyPath: key} +} + +func executeDecisionPrepareV4(o DecisionPrepareOptions, ceremonyID string, draft []byte) (CommandResult, error) { + if err := validateDecisionOutputV4(o.EvidenceRoot, o.OutPath); err != nil { + return CommandResult{}, err + } + d, data, err := mpcceremony.PrepareProductionDecisionV4(decisionTrustV4(o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile), o.EvidenceRoot, draft) + if err != nil { + return CommandResult{}, err + } + if err := checkDecisionCeremonyV4(d, ceremonyID); err != nil { + return CommandResult{}, err + } + if err := writeFreshOperationalFile(o.OutPath, data, 0o600); err != nil { + return CommandResult{}, err + } + return decisionCommandResultV4(d, "Prepared exact decision and verified local evidence; no decision signature or publication was created.", map[string]string{"decision": o.OutPath}), nil +} + +func executeDecisionSignV4(o DecisionSignOptions, ceremonyID string, data []byte) (CommandResult, error) { + if err := validateDecisionOutputV4(o.EvidenceRoot, o.OutPath); err != nil { + return CommandResult{}, err + } + verification := mpcceremony.VerifyProductionDecisionEvidenceV4Options{ + Trust: decisionTrustV4(o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile), ArtifactRoot: o.EvidenceRoot, DecisionBytes: data, + } + verified, err := mpcceremony.VerifyProductionDecisionEvidenceV4(verification) + if err != nil { + return CommandResult{}, fmt.Errorf("refuse to sign unverified decision evidence: %w", err) + } + if err := checkDecisionCeremonyV4(verified.Decision, ceremonyID); err != nil { + return CommandResult{}, err + } + // Evidence must pass before loading the private key. The signing API rechecks it. + privateKey, _, err := keybundle.LoadExistingPrivateKey(o.SigningKey) + if err != nil { + return CommandResult{}, err + } + signature, err := mpcceremony.SignProductionDecisionV4(verification, mpcceremony.DecisionSignerRole(o.Role), o.SignerID, privateKey) + if err != nil { + return CommandResult{}, err + } + if err := writeFreshOperationalFile(o.OutPath, signature, 0o600); err != nil { + return CommandResult{}, err + } + return decisionCommandResultV4(verified.Decision, "Signed this exact decision with one role key; this alone does not establish the required signature set or publish files.", map[string]string{"decision": o.DecisionPath, "signature": o.OutPath}), nil +} + +func executeDecisionVerifyV4(o DecisionVerifyOptions, ceremonyID string, data []byte, signatures [][]byte) (CommandResult, error) { + if o.EvidenceRoot == "" { + return CommandResult{}, errors.New("--evidence-root is required for definition v4 decisions") + } + verified, err := mpcceremony.VerifyProductionDecisionV4(mpcceremony.VerifyProductionDecisionV4Options{ + VerifyProductionDecisionEvidenceV4Options: mpcceremony.VerifyProductionDecisionEvidenceV4Options{ + Trust: decisionTrustV4(o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile), ArtifactRoot: o.EvidenceRoot, DecisionBytes: data, + }, SignatureBytes: signatures, + }) + if err != nil { + return CommandResult{}, err + } + if err := checkDecisionCeremonyV4(verified.Decision, ceremonyID); err != nil { + return CommandResult{}, err + } + return decisionCommandResultV4(verified.Decision, fmt.Sprintf("Verified %s decision, %d exact role signatures and local release/evidence bindings; no files were published.", verified.Decision.Decision, len(verified.VerifiedSigners)), map[string]string{"decision": o.DecisionPath, "evidence_root": o.EvidenceRoot}), nil +} + +func checkDecisionCeremonyV4(d mpcceremony.ProductionDecisionV3, expected string) error { + if d.CeremonyID != expected { + return errors.New("authenticated ceremony changed during decision verification") + } + return nil +} + +func decisionCommandResultV4(d mpcceremony.ProductionDecisionV3, summary string, outputs map[string]string) CommandResult { + return CommandResult{CeremonyID: d.CeremonyID, Decision: string(d.Decision), DecisionID: d.DecisionID, + ReleaseID: d.Release.ReleaseID, CandidateID: d.Release.CandidateID, SourceCommit: d.SourceRelease.SourceCommit, + Summary: summary, Outputs: outputs} +} + +// Decision files belong outside the closed release package. The fresh writer +// still requires an existing parent and refuses to replace any existing leaf. +func validateDecisionOutputV4(root, out string) error { + if root == "" { + return errors.New("--evidence-root is required for definition v4 decisions") + } + return validatePathOutsideTree(root, "final/release", out) +} + +// The subtree may not exist yet, but root and the supplied path's parent must. +// This guards accidental placement, not a malicious concurrent parent swap. +func validatePathOutsideTree(root, subtree, out string) error { + packagePath, err := filepath.Abs(filepath.Join(root, filepath.FromSlash(subtree))) + if err != nil { + return err + } + outputPath, err := filepath.Abs(out) + if err != nil { + return err + } + inside := func(base, path string) bool { + rel, err := filepath.Rel(base, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) + } + if inside(packagePath, outputPath) { + return errors.New("path must be outside the closed artifact tree") + } + resolvedRoot, err := filepath.EvalSymlinks(root) + if err != nil { + return err + } + resolvedRoot, err = filepath.Abs(resolvedRoot) + if err != nil { + return err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(outputPath)) + if err != nil { + return err + } + if inside(filepath.Join(resolvedRoot, filepath.FromSlash(subtree)), filepath.Join(parent, filepath.Base(outputPath))) { + return errors.New("path resolves inside the closed artifact tree") + } + return nil +} diff --git a/cmd/mpc-ceremony/decision_v4_test.go b/cmd/mpc-ceremony/decision_v4_test.go new file mode 100644 index 00000000..52f9d1c9 --- /dev/null +++ b/cmd/mpc-ceremony/decision_v4_test.go @@ -0,0 +1,140 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "proof-tool/internal/mpcceremony" +) + +func TestDecisionV4OutputOutsideClosedPackage(t *testing.T) { + root := t.TempDir() + for _, dir := range []string{"final/release/nested", "final/release-old", "decision"} { + if err := os.MkdirAll(filepath.Join(root, dir), 0o700); err != nil { + t.Fatal(err) + } + } + if err := os.Symlink(filepath.Join(root, "final/release"), filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + for _, tc := range []struct { + path string + bad bool + }{ + {"final/release/decision.json", true}, {"final/release/nested/signature.json", true}, + {"alias/signature.json", true}, {"final/release-old/decision.json", false}, {"decision/record.json", false}, + } { + t.Run(tc.path, func(t *testing.T) { + err := validateDecisionOutputV4(root, filepath.Join(root, tc.path)) + if (err != nil) != tc.bad { + t.Fatalf("error = %v, want rejection %v", err, tc.bad) + } + }) + } + if err := validateDecisionOutputV4("", filepath.Join(root, "decision.json")); err == nil { + t.Fatal("missing evidence root accepted") + } +} + +func TestDecisionV4AuthenticatedDispatchRejectsLegacyBeforeKey(t *testing.T) { + root := t.TempDir() + d, legacy, key := decisionSignFixture(t) + d.Schema = mpcceremony.DefinitionSchemaV4 + d.ReleaseVerification = "coordinator-full-replay-v1" + var err error + d, err = mpcceremony.FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + db, sig, err := mpcceremony.SignRecord(d, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + ceremony, signature, publicKey := filepath.Join(root, "ceremony.json"), filepath.Join(root, "ceremony.sig"), filepath.Join(root, "coordinator.hex") + writeDecisionTestFile(t, ceremony, db, 0o600) + writeDecisionTestFile(t, signature, sig, 0o600) + writeDecisionTestFile(t, publicKey, []byte(d.Coordinator.Ed25519PublicKeyHex), 0o600) + decision := filepath.Join(root, "decision.json") + writeDecisionTestFile(t, decision, legacy, 0o600) + out := filepath.Join(root, "out.json") + sign := DecisionSignOptions{CeremonyPath: ceremony, CeremonySignaturePath: signature, CoordinatorPublicKeyFile: publicKey, + DecisionPath: decision, SigningKey: filepath.Join(root, "MISSING-PRIVATE-KEY"), OutPath: out, + Role: "coordinator", SignerID: d.Coordinator.ID} + if _, err := executeDecisionSign(sign); err == nil || !strings.Contains(err.Error(), "--evidence-root") { + t.Fatalf("missing root: %v", err) + } + sign.EvidenceRoot = root + if _, err := executeDecisionSign(sign); err == nil || strings.Contains(err.Error(), "MISSING-PRIVATE-KEY") { + t.Fatalf("must reject legacy evidence before loading key: %v", err) + } + prepare := DecisionPrepareOptions{CeremonyPath: ceremony, CeremonySignaturePath: signature, CoordinatorPublicKeyFile: publicKey, + DraftPath: decision, OutPath: out} + if _, err := executeDecisionPrepare(prepare); err == nil || !strings.Contains(err.Error(), "--evidence-root") { + t.Fatalf("prepare missing root: %v", err) + } + prepare.EvidenceRoot = root + if _, err := executeDecisionPrepare(prepare); err == nil { + t.Fatal("legacy draft accepted as v4") + } + verify := DecisionVerifyOptions{CeremonyPath: ceremony, CeremonySignaturePath: signature, CoordinatorPublicKeyFile: publicKey, + DecisionPath: decision, EvidenceRoot: root} + if _, err := executeDecisionVerify(verify); err == nil { + t.Fatal("legacy decision verified as v4") + } + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("failed commands produced output: %v", err) + } + // A valid signature is required to select any new behavior. + writeDecisionTestFile(t, signature, []byte("{}"), 0o600) + if _, err := executeDecisionSign(sign); err == nil || strings.Contains(err.Error(), "unverified decision evidence") { + t.Fatalf("dispatch preceded authentication: %v", err) + } +} + +func TestDecisionPrepareEvidenceRootIsVersionSpecific(t *testing.T) { + args := []string{"--ceremony", "ceremony.json", "--ceremony-signature", "ceremony.sig", "--coordinator-public-key-file", "key.hex", "--draft", "draft.json", "--out", "out.json"} + if _, err := parseDecisionPrepare(args); err != nil { + t.Fatal(err) + } + if o, err := parseDecisionPrepare(append(args, "--evidence-root", "evidence")); err != nil || o.EvidenceRoot != "evidence" { + t.Fatalf("parse: %+v %v", o, err) + } + if _, err := parseDecisionPrepare(append(args, "--evidence-root", "https://example.invalid/evidence")); err == nil { + t.Fatal("invalid evidence path accepted") + } + root := t.TempDir() + d, data, key := decisionSignFixture(t) + db, sig, err := mpcceremony.SignRecord(d, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + for name, bytes := range map[string][]byte{"ceremony.json": db, "ceremony.sig": sig, "key.hex": []byte(d.Coordinator.Ed25519PublicKeyHex), "draft.json": data} { + writeDecisionTestFile(t, filepath.Join(root, name), bytes, 0o600) + } + _, err = executeDecisionPrepare(DecisionPrepareOptions{CeremonyPath: filepath.Join(root, "ceremony.json"), CeremonySignaturePath: filepath.Join(root, "ceremony.sig"), CoordinatorPublicKeyFile: filepath.Join(root, "key.hex"), DraftPath: filepath.Join(root, "draft.json"), OutPath: filepath.Join(root, "out.json"), EvidenceRoot: root}) + if err == nil || !strings.Contains(err.Error(), "only supported for definition v4") { + t.Fatalf("legacy flag silently ignored: %v", err) + } +} + +func TestDecisionV4ResultAndReloadBinding(t *testing.T) { + d := mpcceremony.ProductionDecisionV3{CeremonyID: "expected"} + if err := checkDecisionCeremonyV4(d, "changed"); err == nil { + t.Fatal("changed ceremony accepted") + } + if err := checkDecisionCeremonyV4(d, "expected"); err != nil { + t.Fatal(err) + } + b, err := json.Marshal(decisionCommandResultV4(d, "verified", nil)) + if err != nil { + t.Fatal(err) + } + for _, obsolete := range []string{"source_signed_tag", "source_tag_signer", "source_tag_object", "release_manifest_sha256"} { + if strings.Contains(string(b), obsolete) { + t.Fatalf("obsolete field emitted: %s", b) + } + } +} diff --git a/cmd/mpc-ceremony/definition_protocol.go b/cmd/mpc-ceremony/definition_protocol.go new file mode 100644 index 00000000..22cb93c2 --- /dev/null +++ b/cmd/mpc-ceremony/definition_protocol.go @@ -0,0 +1,34 @@ +package main + +import m "proof-tool/internal/mpcceremony" + +// This is a separate projection so existing definition inspection consumers +// retain their wire format. StorageWorkflow is derived from the authenticated +// definition format, not an unauthenticated backend hint. +type DefinitionProtocolInspection struct { + Schema string `json:"schema"` + DefinitionSchema string `json:"definition_schema"` + StorageWorkflow string `json:"storage_workflow"` + ReleaseVerification string `json:"release_verification"` + Definition DefinitionInspection `json:"definition"` + DefinitionRefs m.SignedArtifactRefs `json:"definition_refs"` +} + +func executeInspectDefinitionProtocol(o InspectDefinitionOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(o) + if err != nil { + return CommandResult{}, err + } + d := trusted.Definition + workflow := m.StorageFirstWorkflowV1 + if d.Schema == m.DefinitionSchemaV4 { + workflow = m.StorageFirstWorkflowV2 + } + inspection := DefinitionProtocolInspection{ + Schema: "proof-tool-mpc-definition-protocol-inspection-v1", + DefinitionSchema: d.Schema, StorageWorkflow: workflow, + ReleaseVerification: d.ReleaseVerification, Definition: inspectDefinition(d), + DefinitionRefs: trusted.DefinitionRefs, + } + return CommandResult{CeremonyID: d.CeremonyID, Summary: "Authenticated definition protocol and schedules; no backend state or contribution mathematics verified.", DefinitionProtocolInspection: &inspection}, nil +} diff --git a/cmd/mpc-ceremony/definition_protocol_test.go b/cmd/mpc-ceremony/definition_protocol_test.go new file mode 100644 index 00000000..6a624372 --- /dev/null +++ b/cmd/mpc-ceremony/definition_protocol_test.go @@ -0,0 +1,92 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "testing" + + "golang.org/x/crypto/blake2b" + + m "proof-tool/internal/mpcceremony" +) + +func TestDefinitionProtocolAuthenticatedDispatch(t *testing.T) { + for _, schema := range []string{m.DefinitionSchemaV1, m.DefinitionSchemaV2, m.DefinitionSchemaV3, m.DefinitionSchemaV4} { + d, _, key := decisionSignFixture(t) + d.Schema = schema + v4 := schema == m.DefinitionSchemaV4 + if schema == m.DefinitionSchemaV1 || schema == m.DefinitionSchemaV2 { + d.AssurancePolicy = nil + } + if schema == m.DefinitionSchemaV1 { + d.Software.Binaries = nil + d.Software.GoARM64 = "" + } + if v4 { + d.ReleaseVerification = m.CoordinatorReplayReleaseV1 + } + var err error + d.CeremonyID, err = m.ComputeCeremonyID(d) + if err != nil { + t.Fatal(err) + } + args := writeInspectionTrustFixture(t, t.TempDir(), d, key) + command := append([]string{"--format", "json", "inspect", "definition-protocol"}, args...) + var out, stderr bytes.Buffer + if code := runCLI(context.Background(), command, &out, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("%s", stderr.String()) + } + var result CommandResult + if err := json.Unmarshal(out.Bytes(), &result); err != nil { + t.Fatal(err) + } + p := result.DefinitionProtocolInspection + want := m.StorageFirstWorkflowV1 + if v4 { + want = m.StorageFirstWorkflowV2 + } + if p == nil || p.DefinitionSchema != d.Schema || p.StorageWorkflow != want || p.ReleaseVerification != d.ReleaseVerification || p.Definition.CeremonyID != d.CeremonyID || result.DefinitionInspection != nil { + t.Fatalf("unexpected projection: %+v", result) + } + for flag, ref := range map[string]m.ArtifactRef{"--ceremony": p.DefinitionRefs.Record, "--ceremony-signature": p.DefinitionRefs.Signature} { + var path string + for i := range args { + if args[i] == flag { + path = args[i+1] + } + } + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + wantName := "ceremony.json" + if flag == "--ceremony-signature" { + wantName = "ceremony.sig" + } + if ref.Name != wantName || ref.Digest.Size != int64(len(data)) || ref.Digest.SHA256 != fmt.Sprintf("sha256:%x", sha256.Sum256(data)) || ref.Digest.Blake2b256 != fmt.Sprintf("blake2b256:%x", blake2b.Sum256(data)) { + t.Fatalf("reference does not bind exact authenticated bytes: %+v", ref) + } + } + out.Reset() + stderr.Reset() + legacyCommand := append([]string{"--format", "json", "inspect", "definition"}, args...) + if code := runCLI(context.Background(), legacyCommand, &out, &stderr, workflowExecutor{}); code != 0 || bytes.Contains(out.Bytes(), []byte("definition_protocol_inspection")) { + t.Fatalf("legacy inspection changed: %s %s", out.String(), stderr.String()) + } + // A failed authentication emits no format selector for fallback routing. + for i := range command { + if command[i] == "--coordinator-public-key-file" { + command[i+1] += ".missing" + } + } + out.Reset() + stderr.Reset() + if code := runCLI(context.Background(), command, &out, &stderr, workflowExecutor{}); code == 0 || bytes.Contains(out.Bytes(), []byte("definition_protocol_inspection")) { + t.Fatal("failed trust emitted protocol") + } + } +} diff --git a/cmd/mpc-ceremony/evidence_v4.go b/cmd/mpc-ceremony/evidence_v4.go new file mode 100644 index 00000000..953591a1 --- /dev/null +++ b/cmd/mpc-ceremony/evidence_v4.go @@ -0,0 +1,334 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + + "proof-tool/internal/keybundle" + m "proof-tool/internal/mpcceremony" +) + +// These commands deliberately take an exact checkpoint pair. Legacy discovery +// and record-signing commands cannot supply that boundary. +type EvidenceOptionsV4 struct { + InspectDefinitionOptions + ArtifactRoot, CheckpointPath, CheckpointSignaturePath string + BundlePath, BundleSignaturePath, OutPath string + AssembledAt, ReleasedAt, CoordinatorSigningKey string + Reviewed bool + ReviewedSHA256 string +} + +const maxEvidenceReportV4Bytes = 64 << 20 + +type EvidenceInspectionV4 struct { + Schema string `json:"schema"` + SourceCheckpoint m.SignedArtifactRefs `json:"source_checkpoint"` + OperationalBundle *m.SignedArtifactRefs `json:"operational_bundle,omitempty"` + AssembledAt string `json:"assembled_at,omitempty"` + ReleasedAt string `json:"released_at,omitempty"` + OutputDigest m.Digest `json:"output_digest"` +} + +// This projection is a local diagnostic, never an input authority. Consumers +// must authenticate the signed bootstrap and rerun package verification. +type ReleaseInventoryReportV4 struct { + Schema string `json:"schema"` + Release m.FinalReleaseEvidenceV4 `json:"release"` + ManifestSHA256 string `json:"manifest_sha256"` + PackagePrefix string `json:"package_prefix"` + Artifacts []m.ArtifactRef `json:"artifacts"` + Depth string `json:"depth"` + ArtifactsVerified bool `json:"artifacts_verified"` + CoordinatorReplayClaimBound bool `json:"coordinator_replay_claim_bound"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` + ProductionAuthorized bool `json:"production_authorized"` + Published bool `json:"published"` +} + +func (r ReleaseInventoryReportV4) Validate() error { + if r.Schema != "proof-tool-mpc-release-inventory-report-v4" || r.Depth != "final-package" || r.PackagePrefix != m.FinalReleasePackagePrefixV4 { + return errors.New("unsupported final package inventory report") + } + if err := r.Release.Validate(); err != nil { + return err + } + if !strings.HasPrefix(r.ManifestSHA256, "sha256:") || validateBundleReviewV4(EvidenceOptionsV4{Reviewed: true, ReviewedSHA256: strings.TrimPrefix(r.ManifestSHA256, "sha256:")}) != nil { + return errors.New("manifest digest must be tagged lowercase SHA-256") + } + if !r.ArtifactsVerified || !r.CoordinatorReplayClaimBound || r.MathematicsReplayed || r.GlobalFreshnessVerified || r.ProductionAuthorized || r.Published { + return errors.New("inventory report has unsupported verification claims") + } + return m.ValidateFinalReleaseInventoryArtifactsV4(r.Artifacts) +} + +func parseEvidenceV4(command Command, args []string) (EvidenceOptionsV4, error) { + var o EvidenceOptionsV4 + f := commandFlagSet(string(command)) + addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + f.StringVar(&o.ArtifactRoot, "artifact-root", "", "local authenticated ceremony artifact root") + f.StringVar(&o.CheckpointPath, "checkpoint", "", "exact signed checkpoint under artifact-root") + f.StringVar(&o.CheckpointSignaturePath, "checkpoint-signature", "", "exact checkpoint signature under artifact-root") + outFlag := "--out" + switch command { + case CommandOpsPrepareBundleV4: + f.StringVar(&o.AssembledAt, "assembled-at", "", "nonzero UTC assembly time") + case CommandOpsSignBundleV4: + f.StringVar(&o.BundlePath, "operational-bundle", "", "canonical artifact-root/operational/evidence-bundle.json") + f.StringVar(&o.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") + f.BoolVar(&o.Reviewed, "reviewed", false, "owner reviewed these exact bundle bytes") + f.StringVar(&o.ReviewedSHA256, "reviewed-sha256", "", "lowercase SHA-256 of exact reviewed bytes") + case CommandReleaseReviewV4: + f.StringVar(&o.BundlePath, "operational-bundle", "", "canonical operational evidence bundle") + f.StringVar(&o.BundleSignaturePath, "operational-bundle-signature", "", "canonical operational bundle signature") + f.StringVar(&o.ReleasedAt, "released-at", "", "nonzero UTC proposed package time") + case CommandCheckpointVerifyReleaseV4: + outFlag = "--inventory-out" + default: + return o, errors.New("unknown V4 evidence command") + } + f.StringVar(&o.OutPath, outFlag[2:], "", "fresh output file; real parent directory must exist") + if err := parseFlags(f, args); err != nil { + return o, err + } + if err := requireValues(pathValue("--ceremony", o.CeremonyPath), pathValue("--ceremony-signature", o.CeremonySignaturePath), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), pathValue("--artifact-root", o.ArtifactRoot), pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath), pathValue(outFlag, o.OutPath)); err != nil { + return o, err + } + switch command { + case CommandOpsPrepareBundleV4: + _, err := parseUTCTime("--assembled-at", o.AssembledAt) + return o, err + case CommandOpsSignBundleV4: + if err := validateBundleReviewV4(o); err != nil { + return o, err + } + return o, requireValues(pathValue("--operational-bundle", o.BundlePath), pathValue("--coordinator-signing-key", o.CoordinatorSigningKey)) + case CommandReleaseReviewV4: + if _, err := parseUTCTime("--released-at", o.ReleasedAt); err != nil { + return o, err + } + return o, requireValues(pathValue("--operational-bundle", o.BundlePath), pathValue("--operational-bundle-signature", o.BundleSignaturePath)) + } + return o, nil +} + +func validateBundleReviewV4(o EvidenceOptionsV4) error { + if !o.Reviewed || len(o.ReviewedSHA256) != 64 { + return errors.New("bundle signing requires --reviewed and --reviewed-sha256 of the exact canonical bytes") + } + for _, c := range o.ReviewedSHA256 { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { + return errors.New("reviewed SHA-256 must be 64 lowercase hexadecimal characters") + } + } + return nil +} + +func executeEvidenceV4(command Command, o EvidenceOptionsV4) (CommandResult, error) { + trust := trustPaths(o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile) + trusted, err := m.LoadSignedDefinition(trust) + if err != nil { + return CommandResult{}, err + } + d := trusted.Definition + if d.Schema != m.DefinitionSchemaV4 { + return CommandResult{}, errors.New("V4 evidence commands require definition v4") + } + if err := m.VerifyRunningSoftwareForMode(d.Software, d.Mode); err != nil { + return CommandResult{}, err + } + if err := validateEvidenceOutputV4(command, o); err != nil { + return CommandResult{}, err + } + _, _, head, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) + if err != nil { + return CommandResult{}, err + } + result := CommandResult{CeremonyID: d.CeremonyID, Outputs: map[string]string{"checkpoint": o.CheckpointPath, "checkpoint_signature": o.CheckpointSignaturePath}} + result.EvidenceInspectionV4 = &EvidenceInspectionV4{Schema: "proof-tool-mpc-evidence-inspection-v4", SourceCheckpoint: head} + var data []byte + limit := maxEvidenceReportV4Bytes + switch command { + case CommandOpsPrepareBundleV4, CommandOpsSignBundleV4: + limit = maxOperationalRecordBytes + at, timeErr := parseUTCTime("--assembled-at", o.AssembledAt) + var reviewed []byte + if command == CommandOpsSignBundleV4 { + if err := validateBundleReviewV4(o); err != nil { + return CommandResult{}, err + } + if err := requireCanonicalBundlePathV4(o.ArtifactRoot, o.BundlePath, m.OperationalEvidenceBundleFile); err != nil { + return CommandResult{}, err + } + reviewed, _, err = checkpointArtifactBytes(o.ArtifactRoot, o.BundlePath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + if fmt.Sprintf("%x", sha256.Sum256(reviewed)) != o.ReviewedSHA256 { + return CommandResult{}, errors.New("bundle changed since owner review") + } + var bundle m.OperationalEvidenceBundle + if err = m.UnmarshalCanonical(reviewed, &bundle); err != nil { + return CommandResult{}, err + } + at, timeErr = parseUTCTime("bundle assembled_at", bundle.AssembledAt) + } + if timeErr != nil { + return CommandResult{}, timeErr + } + prepared, err := m.PrepareOperationalBundleV4(trust, o.ArtifactRoot, head, at) + if err != nil { + return CommandResult{}, err + } + if prepared.SourceCheckpoint != head || prepared.Bundle.CeremonyID != d.CeremonyID { + return CommandResult{}, errors.New("authenticated ceremony or checkpoint changed during bundle preparation") + } + result.EvidenceInspectionV4.AssembledAt = prepared.Bundle.AssembledAt + data, err = m.MarshalCanonical(prepared.Bundle) + if err != nil { + return CommandResult{}, err + } + result.Summary = "Prepared an unsigned bundle from this exact checkpoint. Signing must recheck the same checkpoint and reviewed bytes. No contribution replay or release approval occurred." + result.Outputs["canonical"] = o.OutPath + if command == CommandOpsSignBundleV4 { + if !bytes.Equal(data, reviewed) { + return CommandResult{}, errors.New("reviewed bundle does not match the exact checkpoint; prepare and review the correct bundle") + } + private, public, err := keybundle.LoadExistingPrivateKey(o.CoordinatorSigningKey) + if err != nil { + return CommandResult{}, err + } + if !bytes.Equal(public, trusted.CoordinatorPublicKey) { + return CommandResult{}, errors.New("bundle signing key is not the authenticated coordinator key") + } + sig, err := m.SignExact(data, d.Coordinator.KeyID, private) + if err != nil { + return CommandResult{}, err + } + data, err = m.MarshalCanonical(sig) + if err != nil { + return CommandResult{}, err + } + result.Outputs["canonical"] = o.BundlePath + result.Outputs["signature"] = o.OutPath + result.Summary = "Signed the reviewed bundle after rederiving it from this exact checkpoint. This does not approve release or establish global freshness." + limit = 4096 + } + case CommandReleaseReviewV4: + at, err := parseUTCTime("--released-at", o.ReleasedAt) + if err != nil { + return CommandResult{}, err + } + _, _, pair, err := checkpointSignedBytes(o.ArtifactRoot, o.BundlePath, o.BundleSignaturePath) + if err != nil { + return CommandResult{}, err + } + review, err := m.VerifyReleaseReviewV4(trust, o.ArtifactRoot, head, pair, at) + if err != nil { + return CommandResult{}, err + } + if review.CeremonyID != d.CeremonyID || review.ReviewCheckpoint != head || review.OperationalBundle != pair { + return CommandResult{}, errors.New("authenticated ceremony or inputs changed during release review") + } + data, err = m.MarshalCanonical(review) + if err != nil { + return CommandResult{}, err + } + result.Outputs["review"] = o.OutPath + result.Outputs["artifact_count"] = strconv.Itoa(len(review.RequiredArtifacts)) + result.Outputs["operational_bundle"] = o.BundlePath + result.Outputs["operational_bundle_signature"] = o.BundleSignaturePath + result.Outputs["released_at"] = review.ReleasedAt + result.EvidenceInspectionV4.OperationalBundle = &pair + result.EvidenceInspectionV4.ReleasedAt = review.ReleasedAt + result.Summary = "Verified exact review files, signatures, required evidence and coordinator replay binding. This unsigned report is not an authorization or signing input; release signing recomputes it. No contribution replay, freshness check or publication occurred." + case CommandCheckpointVerifyReleaseV4: + verified, inventory, err := m.VerifyFinalReleaseCheckpointV4(trust, o.ArtifactRoot, head) + if err != nil { + return CommandResult{}, err + } + if verified.Transcript.CeremonyID != d.CeremonyID { + return CommandResult{}, errors.New("authenticated ceremony changed during final package verification") + } + binding, err := m.NewFinalReleaseEvidenceV4(d.CeremonyID, head, verified.Candidate.CandidateID) + if err != nil { + return CommandResult{}, err + } + report := ReleaseInventoryReportV4{Schema: "proof-tool-mpc-release-inventory-report-v4", Release: binding, ManifestSHA256: verified.ManifestSHA256, + PackagePrefix: inventory.PackagePrefix(), Artifacts: inventory.Artifacts(), Depth: "final-package", ArtifactsVerified: true, CoordinatorReplayClaimBound: true} + if err := report.Validate(); err != nil { + return CommandResult{}, err + } + data, err = m.MarshalCanonical(report) + if err != nil { + return CommandResult{}, err + } + result.ReleaseID, result.CandidateID, result.ReleaseManifestSHA256 = binding.ReleaseID, binding.CandidateID, verified.ManifestSHA256 + result.Outputs["inventory"] = o.OutPath + result.Outputs["artifact_count"] = strconv.Itoa(len(report.Artifacts)) + result.Outputs["package_prefix"] = report.PackagePrefix + result.Summary = "Verified checkpoint ancestry, the exact private package, required evidence and coordinator replay binding. The unsigned inventory is a local report, not download authority. No contribution replay, global freshness, production approval or publication occurred." + default: + return CommandResult{}, errors.New("unknown V4 evidence command") + } + if len(data) == 0 || len(data) > limit { + return CommandResult{}, errors.New("V4 evidence output exceeds its format size bound") + } + if err := writeFreshOperationalFile(o.OutPath, data, 0600); err != nil { + return CommandResult{}, err + } + result.Outputs["output_sha256"] = fmt.Sprintf("%x", sha256.Sum256(data)) + result.EvidenceInspectionV4.OutputDigest = m.NewDigest(data) + return result, nil +} + +func requireCanonicalBundlePathV4(root, file, name string) error { + r, err := filepath.Abs(root) + if err != nil { + return err + } + p, err := filepath.Abs(file) + if err != nil || p != filepath.Join(r, filepath.FromSlash(name)) { + return fmt.Errorf("bundle path must be artifact-root/%s", name) + } + return validateCheckpointPathComponents(r, filepath.Dir(p)) +} + +func validateEvidenceOutputV4(command Command, o EvidenceOptionsV4) error { + parent, err := os.Lstat(filepath.Dir(o.OutPath)) + if err != nil || !parent.IsDir() || parent.Mode()&os.ModeSymlink != 0 { + return errors.New("evidence output requires an existing real parent directory") + } + switch command { + case CommandOpsPrepareBundleV4: + if err := requireCanonicalBundlePathV4(o.ArtifactRoot, o.OutPath, m.OperationalEvidenceBundleFile); err != nil { + return err + } + if _, err := os.Lstat(filepath.Join(o.ArtifactRoot, m.OperationalEvidenceSignatureFile)); !errors.Is(err, os.ErrNotExist) { + return errors.New("bundle signature already exists or cannot be inspected; retain the existing pair for review") + } + case CommandOpsSignBundleV4: + if err := requireCanonicalBundlePathV4(o.ArtifactRoot, o.OutPath, m.OperationalEvidenceSignatureFile); err != nil { + return err + } + case CommandReleaseReviewV4, CommandCheckpointVerifyReleaseV4: + for _, subtree := range []string{"final/candidate", "final/release"} { + if err := validatePathOutsideTree(o.ArtifactRoot, subtree, o.OutPath); err != nil { + return err + } + } + default: + return errors.New("unknown V4 evidence command") + } + if _, err := os.Lstat(o.OutPath); !errors.Is(err, os.ErrNotExist) { + return errors.New("evidence output already exists or cannot be inspected; retain it for review") + } + return nil +} diff --git a/cmd/mpc-ceremony/evidence_v4_test.go b/cmd/mpc-ceremony/evidence_v4_test.go new file mode 100644 index 00000000..4d19b6ad --- /dev/null +++ b/cmd/mpc-ceremony/evidence_v4_test.go @@ -0,0 +1,545 @@ +package main + +import ( + "bytes" + "crypto/sha256" + "encoding/json" + "fmt" + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "golang.org/x/sys/cpu" + + m "proof-tool/internal/mpcceremony" +) + +func evidenceArgsV4(command Command) []string { + a := []string{"--ceremony", "ceremony.json", "--ceremony-signature", "ceremony.sig", "--coordinator-public-key-file", "coordinator.hex", "--artifact-root", "root", "--checkpoint", "root/head.json", "--checkpoint-signature", "root/head.sig"} + switch command { + case CommandOpsPrepareBundleV4: + return append(a, "--assembled-at", "2026-09-16T00:00:00Z", "--out", "root/operational/evidence-bundle.json") + case CommandOpsSignBundleV4: + return append(a, "--operational-bundle", "root/operational/evidence-bundle.json", "--coordinator-signing-key", "private.hex", "--reviewed", "--reviewed-sha256", strings.Repeat("a", 64), "--out", "root/operational/evidence-bundle.sig") + case CommandReleaseReviewV4: + return append(a, "--operational-bundle", "root/operational/evidence-bundle.json", "--operational-bundle-signature", "root/operational/evidence-bundle.sig", "--released-at", "2026-09-16T00:00:00Z", "--out", "report.json") + default: + return append(a, "--inventory-out", "inventory.json") + } +} + +func TestEvidenceV4ParsersRequireExactInputs(t *testing.T) { + for _, command := range []Command{CommandOpsPrepareBundleV4, CommandOpsSignBundleV4, CommandReleaseReviewV4, CommandCheckpointVerifyReleaseV4} { + t.Run(string(command), func(t *testing.T) { + a := evidenceArgsV4(command) + if _, err := parseEvidenceV4(command, a); err != nil { + t.Fatal(err) + } + invocation, err := parseInvocation(append(strings.Split(string(command), " "), a...)) + if err != nil || invocation.Command != command { + t.Fatalf("dispatch: %+v %v", invocation, err) + } + for i := 0; i < len(a); i++ { + if !strings.HasPrefix(a[i], "--") { + continue + } + end := i + 2 + if a[i] == "--reviewed" { + end = i + 1 + } + missing := append(append([]string{}, a[:i]...), a[end:]...) + if _, err := parseEvidenceV4(command, missing); err == nil { + t.Fatalf("accepted missing %s", a[i]) + } + } + for _, mixed := range []string{"--candidate-bundle", "--transcript-root", "--proposal", "--review-report"} { + if _, err := parseEvidenceV4(command, append(append([]string{}, a...), mixed, "file")); err == nil { + t.Fatalf("accepted unrelated or unsigned-authority input %s", mixed) + } + } + }) + } + for _, hash := range []string{"", strings.Repeat("a", 63), strings.Repeat("A", 64), strings.Repeat("g", 64)} { + if err := validateBundleReviewV4(EvidenceOptionsV4{Reviewed: true, ReviewedSHA256: hash}); err == nil { + t.Fatal("accepted invalid reviewed hash", hash) + } + } +} + +func TestEvidenceV4OutputPathsAndCollisions(t *testing.T) { + root := t.TempDir() + for _, sub := range []string{"operational", "final/candidate/nested", "final/release", "reports"} { + if err := os.MkdirAll(filepath.Join(root, sub), 0700); err != nil { + t.Fatal(err) + } + } + for _, command := range []Command{CommandOpsPrepareBundleV4, CommandOpsSignBundleV4, CommandReleaseReviewV4, CommandCheckpointVerifyReleaseV4} { + o := EvidenceOptionsV4{ArtifactRoot: root, OutPath: filepath.Join(root, "reports/report.json")} + if command == CommandOpsPrepareBundleV4 { + o.OutPath = filepath.Join(root, m.OperationalEvidenceBundleFile) + } + if command == CommandOpsSignBundleV4 { + o.OutPath = filepath.Join(root, m.OperationalEvidenceSignatureFile) + } + if err := validateEvidenceOutputV4(command, o); err != nil { + t.Fatal(command, err) + } + writeDecisionTestFile(t, o.OutPath, []byte("retain"), 0600) + if err := validateEvidenceOutputV4(command, o); err == nil { + t.Fatal("collision accepted", command) + } + if err := os.Remove(o.OutPath); err != nil { + t.Fatal(err) + } + if command == CommandOpsPrepareBundleV4 { + sig := filepath.Join(root, m.OperationalEvidenceSignatureFile) + writeDecisionTestFile(t, sig, []byte("retain existing signature"), 0600) + if err := validateEvidenceOutputV4(command, o); err == nil { + t.Fatal("existing signature was ignored") + } + if err := os.Remove(sig); err != nil { + t.Fatal(err) + } + } + for _, bad := range []string{"final/candidate/report.json", "final/candidate/nested/report.json", "final/release/report.json", "missing/report.json"} { + o.OutPath = filepath.Join(root, bad) + if err := validateEvidenceOutputV4(command, o); err == nil { + t.Fatal("unsafe output accepted", command, bad) + } + } + } + if err := os.Symlink(filepath.Join(root, "final/candidate"), filepath.Join(root, "alias")); err != nil { + t.Fatal(err) + } + if err := validateEvidenceOutputV4(CommandReleaseReviewV4, EvidenceOptionsV4{ArtifactRoot: root, OutPath: filepath.Join(root, "alias/nested/report.json")}); err == nil { + t.Fatal("closed-tree alias accepted") + } + if err := os.Rename(filepath.Join(root, "operational"), filepath.Join(root, "original")); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(root, "original"), filepath.Join(root, "operational")); err != nil { + t.Fatal(err) + } + if err := validateEvidenceOutputV4(CommandOpsPrepareBundleV4, EvidenceOptionsV4{ArtifactRoot: root, OutPath: filepath.Join(root, m.OperationalEvidenceBundleFile)}); err == nil { + t.Fatal("bundle parent alias accepted") + } +} + +func TestEvidenceV4RejectsLegacyDefinitionBeforeOutput(t *testing.T) { + root := t.TempDir() + d, _, key := decisionSignFixture(t) + trustArgs := writeInspectionTrustFixture(t, root, d, key) + for _, command := range []Command{CommandOpsPrepareBundleV4, CommandOpsSignBundleV4, CommandReleaseReviewV4, CommandCheckpointVerifyReleaseV4} { + a := evidenceArgsV4(command) + copy(a[:6], trustArgs[:6]) + o, err := parseEvidenceV4(command, a) + if err != nil { + t.Fatal(err) + } + _, err = executeEvidenceV4(command, o) + if err == nil || !strings.Contains(err.Error(), "require definition v4") { + t.Fatalf("%s: %v", command, err) + } + } +} + +func inventoryReportFixtureV4(t *testing.T) ReleaseInventoryReportV4 { + t.Helper() + ref := func(name string) m.ArtifactRef { return m.ArtifactRef{Name: name, Digest: m.NewDigest([]byte(name))} } + release, err := m.NewFinalReleaseEvidenceV4(m.NewDigest([]byte("ceremony")).SHA256, m.SignedArtifactRefs{Record: ref("checkpoints/final.json"), Signature: ref("checkpoints/final.sig")}, m.NewDigest([]byte("candidate")).SHA256) + if err != nil { + t.Fatal(err) + } + return ReleaseInventoryReportV4{Schema: "proof-tool-mpc-release-inventory-report-v4", Release: release, ManifestSHA256: m.NewDigest([]byte("manifest")).SHA256, + PackagePrefix: m.FinalReleasePackagePrefixV4, Artifacts: []m.ArtifactRef{ref("a.json"), ref("b.sig")}, Depth: "final-package", ArtifactsVerified: true, CoordinatorReplayClaimBound: true} +} + +func TestEvidenceV4InventoryStrictCanonicalClaims(t *testing.T) { + original := inventoryReportFixtureV4(t) + raw, err := m.MarshalCanonical(original) + if err != nil { + t.Fatal(err) + } + var decoded ReleaseInventoryReportV4 + if err := m.UnmarshalCanonical(raw, &decoded); err != nil { + t.Fatal(err) + } + mutations := []func(*ReleaseInventoryReportV4){ + func(r *ReleaseInventoryReportV4) { r.Schema = "unknown" }, + func(r *ReleaseInventoryReportV4) { r.Depth = "checkpoint-structure" }, + func(r *ReleaseInventoryReportV4) { r.PackagePrefix = "other/" }, + func(r *ReleaseInventoryReportV4) { r.Release.ReleaseID = m.NewDigest([]byte("other")).SHA256 }, + func(r *ReleaseInventoryReportV4) { r.ManifestSHA256 = strings.Repeat("a", 64) }, + func(r *ReleaseInventoryReportV4) { r.Artifacts = nil }, + func(r *ReleaseInventoryReportV4) { r.Artifacts[1] = r.Artifacts[0] }, + func(r *ReleaseInventoryReportV4) { r.Artifacts[0], r.Artifacts[1] = r.Artifacts[1], r.Artifacts[0] }, + func(r *ReleaseInventoryReportV4) { r.Artifacts[1].Name = "final/release/b.sig" }, + func(r *ReleaseInventoryReportV4) { r.Artifacts[1].Name = "z:stream" }, + func(r *ReleaseInventoryReportV4) { r.ArtifactsVerified = false }, + func(r *ReleaseInventoryReportV4) { r.CoordinatorReplayClaimBound = false }, + func(r *ReleaseInventoryReportV4) { r.MathematicsReplayed = true }, + func(r *ReleaseInventoryReportV4) { r.GlobalFreshnessVerified = true }, + func(r *ReleaseInventoryReportV4) { r.ProductionAuthorized = true }, + func(r *ReleaseInventoryReportV4) { r.Published = true }, + } + for i, mutate := range mutations { + changed := original + changed.Artifacts = append([]m.ArtifactRef{}, original.Artifacts...) + mutate(&changed) + // json.Marshal deliberately bypasses Validate to construct invalid input. + b, err := json.Marshal(changed) + if err != nil { + t.Fatal(err) + } + if err := m.UnmarshalCanonical(b, &decoded); err == nil { + t.Fatal("accepted inventory mutation", i) + } + } + for _, b := range [][]byte{append(bytes.Clone(raw), '\n'), bytes.Replace(raw, []byte(`"schema":`), []byte(`"unknown":true,"schema":`), 1)} { + if err := m.UnmarshalCanonical(b, &decoded); err == nil { + t.Fatal("noncanonical or unknown field accepted") + } + } +} + +func TestEvidenceV4LargeInventoryReportKeepsDedicatedBound(t *testing.T) { + if testing.Short() { + t.Skip("large local report format boundary") + } + report := inventoryReportFixtureV4(t) + report.Artifacts = make([]m.ArtifactRef, 32000) + for i := range report.Artifacts { + report.Artifacts[i] = m.ArtifactRef{Name: fmt.Sprintf("files/%05d-%s", i, strings.Repeat("a", 470)), Digest: m.NewDigest([]byte("file"))} + } + raw, err := m.MarshalCanonical(report) + if err != nil { + t.Fatal(err) + } + if len(raw) <= maxOperationalRecordBytes || len(raw) > maxEvidenceReportV4Bytes { + t.Fatalf("report size %d does not exercise the dedicated bound", len(raw)) + } + var decoded ReleaseInventoryReportV4 + if err := m.UnmarshalCanonical(raw, &decoded); err != nil { + t.Fatal(err) + } + if len(decoded.Artifacts) != len(report.Artifacts) { + t.Fatal("large inventory lost artifacts") + } +} + +// This exercises the actual CLI (including executable identity) on real tiny +// artifacts produced by the library workflow helper. The two reviewed binaries +// have distinct ARM64 feature variants in the signed allowlist. It is not a +// released role journey, live cloud test or fresh beacon run. +func TestEvidenceV4CommandsOnRealArtifacts(t *testing.T) { + if testing.Short() || runtime.GOOS != "linux" || runtime.GOARCH != "arm64" || !cpu.ARM64.HasATOMICS { + t.Skip("real tiny Linux ARM64 v8.1 command integration") + } + repo, err := filepath.Abs("../..") + if err != nil { + t.Fatal(err) + } + helper := filepath.Join(t.TempDir(), "workflow") + cli := filepath.Join(t.TempDir(), "mpc-ceremony") + buildCLI := exec.Command("go", "build", "-o", cli, "./cmd/mpc-ceremony") + buildCLI.Dir = repo + buildCLI.Env = append(os.Environ(), "GOARM64=v8.1") + if b, err := buildCLI.CombinedOutput(); err != nil { + t.Fatalf("build CLI: %v %s", err, b) + } + build := exec.Command("go", "build", "-o", helper, "./internal/mpcceremony/testdata/workflowhelper") + build.Dir = repo + build.Env = append(os.Environ(), "GOARM64=v8.0") + if b, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, b) + } + runRoot := filepath.Join(t.TempDir(), "run") + run := exec.Command(helper, runRoot) + run.Dir = repo + for _, e := range os.Environ() { + if !strings.HasPrefix(e, "MPC_WORKFLOW_") && !strings.HasPrefix(e, "MPC_CEREMONY_TEST_") && !strings.HasPrefix(e, "PROOF_TOOL_TEST_") { + run.Env = append(run.Env, e) + } + } + run.Env = append(run.Env, "MPC_WORKFLOW_CHECKPOINT_V4=1", "PROOF_TOOL_TEST_ZERO_ASSURANCE=1", "MPC_WORKFLOW_V4_MIRROR=0", "MPC_WORKFLOW_RETAIN_REVIEW=1") + run.Env = append(run.Env, "MPC_WORKFLOW_ALLOWED_CLI="+cli) + if b, err := run.CombinedOutput(); err != nil { + t.Fatalf("fixture: %v %s", err, b) + } + snapshots, err := filepath.Glob(filepath.Join(runRoot, "review-dependencies-*")) + if err != nil || len(snapshots) != 1 { + t.Fatal("missing retained public review branch", snapshots, err) + } + root := snapshots[0] + trust := m.TrustPaths{DefinitionPath: filepath.Join(root, "ceremony.json"), DefinitionSignaturePath: filepath.Join(root, "ceremony.sig"), CoordinatorPublicKeyPath: filepath.Join(runRoot, "ceremony/coordinator-public-key.hex")} + read := func(p string) []byte { + t.Helper() + b, err := os.ReadFile(p) + if err != nil { + t.Fatal(err) + } + return b + } + var transcript m.FinalTranscript + if err := m.UnmarshalCanonical(read(filepath.Join(root, m.FinalReleasePackagePrefixV4, m.FinalTranscriptFile)), &transcript); err != nil { + t.Fatal(err) + } + review := transcript.ReleaseReview + if review == nil { + t.Fatal("missing review") + } + o := EvidenceOptionsV4{InspectDefinitionOptions: InspectDefinitionOptions{CeremonyPath: trust.DefinitionPath, CeremonySignaturePath: trust.DefinitionSignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyPath}, ArtifactRoot: root, + CheckpointPath: filepath.Join(root, review.ReviewCheckpoint.Record.Name), CheckpointSignaturePath: filepath.Join(root, review.ReviewCheckpoint.Signature.Name), + BundlePath: filepath.Join(root, m.OperationalEvidenceBundleFile), BundleSignaturePath: filepath.Join(root, m.OperationalEvidenceSignatureFile), ReleasedAt: review.ReleasedAt, + CoordinatorSigningKey: filepath.Join(runRoot, "identity-keys/coordinator.ed25519.private.hex"), Reviewed: true} + execute := func(c Command, o EvidenceOptionsV4) (CommandResult, error) { + args := append([]string{"--format", "json"}, strings.Split(string(c), " ")...) + args = append(args, "--ceremony", o.CeremonyPath, "--ceremony-signature", o.CeremonySignaturePath, "--coordinator-public-key-file", o.CoordinatorPublicKeyFile, "--artifact-root", o.ArtifactRoot, "--checkpoint", o.CheckpointPath, "--checkpoint-signature", o.CheckpointSignaturePath) + switch c { + case CommandOpsPrepareBundleV4: + args = append(args, "--assembled-at", o.AssembledAt, "--out", o.OutPath) + case CommandOpsSignBundleV4: + args = append(args, "--operational-bundle", o.BundlePath, "--coordinator-signing-key", o.CoordinatorSigningKey, "--reviewed-sha256", o.ReviewedSHA256, "--out", o.OutPath) + if o.Reviewed { + args = append(args, "--reviewed") + } + case CommandReleaseReviewV4: + args = append(args, "--operational-bundle", o.BundlePath, "--operational-bundle-signature", o.BundleSignaturePath, "--released-at", o.ReleasedAt, "--out", o.OutPath) + case CommandCheckpointVerifyReleaseV4: + args = append(args, "--inventory-out", o.OutPath) + } + var stderr bytes.Buffer + process := exec.Command(cli, args...) + process.Stderr = &stderr + b, err := process.Output() + if err != nil { + return CommandResult{}, fmt.Errorf("CLI: %w: %s %s", err, b, stderr.Bytes()) + } + var result CommandResult + if err := json.Unmarshal(b, &result); err != nil { + return result, fmt.Errorf("result: %w: %s", err, b) + } + return result, nil + } + // Publication may be verified by a different platform executable from the + // one that performed the coordinator replay. Both are authenticated by the + // definition's signed allowlist. Re-preparing the historical checkpoint is + // read-only and must accept that distinction; signing it again must not. + // The retained review snapshot is intentionally the minimal release-signer + // input. Revalidating the historical final-candidate checkpoint needs the + // complete coordinator transcript that the checkpoint itself names. + fullRoot := filepath.Join(runRoot, "ceremony") + finalCandidateCheckpoint := filepath.Join(fullRoot, "checkpoints/0014.json") + publicationCopy := filepath.Join(t.TempDir(), "checkpoint.json") + publicationArgs := []string{"--format", "json", "checkpoint", "prepare-v4", + "--ceremony", filepath.Join(fullRoot, "ceremony.json"), "--ceremony-signature", filepath.Join(fullRoot, "ceremony.sig"), + "--coordinator-public-key-file", o.CoordinatorPublicKeyFile, + "--artifact-root", fullRoot, "--proposal", finalCandidateCheckpoint, "--out", publicationCopy} + if b, err := exec.Command(cli, publicationArgs...).CombinedOutput(); err != nil { + t.Fatalf("cross-platform publication verification: %v %s", err, b) + } + if !bytes.Equal(read(finalCandidateCheckpoint), read(publicationCopy)) { + t.Fatal("cross-platform publication verification changed checkpoint bytes") + } + // Rebuild the signing invocation explicitly. Reusing publicationArgs used to + // overwrite the coordinator-key value instead of its old --out destination, + // leaving this cross-platform boundary test unable to exercise signing. + signingArgs := append([]string{}, publicationArgs[:len(publicationArgs)-2]...) + signingArgs[3] = "sign-v4" + signingArgs = append(signingArgs, + "--coordinator-signing-key", filepath.Join(runRoot, "identity-keys/coordinator.ed25519.private.hex"), + "--out", filepath.Join(t.TempDir(), "checkpoint.sig")) + var signedProposal m.CheckpointV4 + if err := m.UnmarshalCanonical(read(finalCandidateCheckpoint), &signedProposal); err != nil { + t.Fatal(err) + } + b, err := exec.Command(cli, signingArgs...).CombinedOutput() + if signedProposal.Transition.Kind == m.CheckpointFinalCandidateRecorded { + if err == nil || !bytes.Contains(b, []byte("executable performing this replay")) { + t.Fatalf("cross-platform executable re-signed another executable's replay claim: %v %s", err, b) + } + } else if err != nil { + t.Fatalf("cross-platform signing of non-replay checkpoint: %v %s", err, b) + } + metadataResult, err := execute(CommandCheckpointInspectEnrollmentsV4, o) + if err != nil { + t.Fatal(err) + } + metadata := metadataResult.EnrollmentMetadataV4 + if metadata == nil || len(metadata.Metadata.Enrollments) == 0 || metadata.Metadata.Checkpoint != review.ReviewCheckpoint || !metadata.EnrollmentSignaturesVerified || metadata.DisclosureContentsVerified || metadata.CompleteRosterVerified || metadata.GlobalFreshnessVerified { + t.Fatalf("committed enrollment inspection: %+v", metadata) + } + structure := metadataResult.CheckpointInspectionV4 + if structure == nil || structure.CheckpointRefs != metadata.Metadata.Checkpoint || len(structure.Commitments.Enrollments) != len(metadata.Metadata.Enrollments) || structure.Depth != "checkpoint-structure" || structure.ArtifactsVerified || structure.MathematicsReplayed || structure.GlobalFreshnessVerified { + t.Fatal("missing or overclaimed combined structure") + } + for n, item := range metadata.Metadata.Enrollments { + if item.Refs != structure.Commitments.Enrollments[n] { + t.Fatal("combined enrollment set mismatch") + } + } + // A committed signature is required on every read; a previous inspection + // cannot substitute for missing or changed bytes. + committedSignature := filepath.Join(root, metadata.Metadata.Enrollments[0].Refs.Signature.Name) + signatureBytes := read(committedSignature) + if err := os.Remove(committedSignature); err != nil { + t.Fatal(err) + } + if _, err := execute(CommandCheckpointInspectEnrollmentsV4, o); err == nil { + t.Fatal("missing committed enrollment signature accepted") + } + writeDecisionTestFile(t, committedSignature, []byte("changed"), 0o600) + if _, err := execute(CommandCheckpointInspectEnrollmentsV4, o); err == nil { + t.Fatal("changed committed enrollment signature accepted") + } + writeDecisionTestFile(t, committedSignature, signatureBytes, 0o600) + original := read(o.BundlePath) + var bundle m.OperationalEvidenceBundle + if err := m.UnmarshalCanonical(original, &bundle); err != nil { + t.Fatal(err) + } + o.AssembledAt = bundle.AssembledAt + // Only known files inside this fresh test directory are replaced. + for _, p := range []string{o.BundlePath, o.BundleSignaturePath} { + if err := os.Remove(p); err != nil { + t.Fatal(err) + } + } + // Appending one byte keeps this test ELF executable runnable while making its + // exact digest unapproved. No production artifact is changed. + approvedCLI := cli + cli = filepath.Join(t.TempDir(), "unapproved-cli") + writeDecisionTestFile(t, cli, append(read(approvedCLI), 0), 0700) + unapproved := o + unapproved.OutPath = o.BundleSignaturePath + unapproved.CoordinatorSigningKey = filepath.Join(root, "MISSING-KEY") + unapproved.ReviewedSHA256 = fmt.Sprintf("%x", sha256.Sum256(original)) + _, unapprovedErr := execute(CommandOpsSignBundleV4, unapproved) + cli = approvedCLI + if unapprovedErr == nil || !strings.Contains(unapprovedErr.Error(), "running software") { + t.Fatal("unapproved executable did not fail at software gate", unapprovedErr) + } + if _, err := os.Lstat(unapproved.OutPath); !os.IsNotExist(err) { + t.Fatal("unapproved executable wrote output") + } + o.OutPath = o.BundlePath + prepared, err := execute(CommandOpsPrepareBundleV4, o) + if err != nil || !bytes.Equal(read(o.BundlePath), original) { + t.Fatalf("prepare: %v", err) + } + if prepared.EvidenceInspectionV4.SourceCheckpoint != review.ReviewCheckpoint { + t.Fatal("source head not exposed") + } + if _, err := execute(CommandOpsPrepareBundleV4, o); err == nil { + t.Fatal("bundle overwrite accepted") + } + o.OutPath = o.BundleSignaturePath + o.ReviewedSHA256 = fmt.Sprintf("%x", sha256.Sum256(original)) + bad := o + bad.ReviewedSHA256 = strings.Repeat("0", 64) + bad.CoordinatorSigningKey = filepath.Join(root, "MISSING-KEY") + if _, err := execute(CommandOpsSignBundleV4, bad); err == nil || !strings.Contains(err.Error(), "changed since owner review") { + t.Fatal("review mismatch did not precede key access", err) + } + bad = o + bad.CoordinatorSigningKey = filepath.Join(runRoot, "identity-keys/participant-01.ed25519.private.hex") + if _, err := execute(CommandOpsSignBundleV4, bad); err == nil || !strings.Contains(err.Error(), "not the authenticated coordinator") { + t.Fatal("wrong key accepted", err) + } + target := filepath.Join(root, bundle.Phase1.AcceptedHeads[0].AcceptedChainPrefix.Record.Name) + saved := read(target) + writeDecisionTestFile(t, target, append(bytes.Clone(saved), '\n'), 0600) + bad.CoordinatorSigningKey = filepath.Join(root, "MISSING-KEY") + _, rejected := execute(CommandOpsSignBundleV4, bad) + writeDecisionTestFile(t, target, saved, 0600) + if rejected == nil || strings.Contains(rejected.Error(), "MISSING-KEY") { + t.Fatal("changed evidence did not fail before key access", rejected) + } + if _, err := os.Lstat(o.OutPath); !os.IsNotExist(err) { + t.Fatal("failed signing wrote output") + } + if _, err := execute(CommandOpsSignBundleV4, o); err != nil { + t.Fatal(err) + } + if _, err := execute(CommandOpsSignBundleV4, o); err == nil { + t.Fatal("signature overwrite accepted") + } + // Generic bundle commands cannot select this checkpoint and must stay closed. + if _, err := executeOpsPrepareBundle(OpsPrepareBundleOptions{CeremonyPath: trust.DefinitionPath, CeremonySignaturePath: trust.DefinitionSignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyPath, EvidenceRoot: root, OutDir: filepath.Join(root, "operational")}); err == nil || !strings.Contains(err.Error(), "prepare-bundle-v4") { + t.Fatal("legacy bundle prepare accepted V4", err) + } + if _, err := executeOpsSign(OpsSignOptions{OpsExportSigningOptions: OpsExportSigningOptions{RecordType: "evidence-bundle", RecordPath: o.BundlePath, CeremonyPath: trust.DefinitionPath, CeremonySignaturePath: trust.DefinitionSignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyPath}, Reviewed: true, ReviewedSHA256: o.ReviewedSHA256, EvidenceRoot: root, SigningKey: "MISSING-KEY", OutPath: filepath.Join(root, "never.sig")}); err == nil || !strings.Contains(err.Error(), "sign-bundle-v4") { + t.Fatal("legacy bundle sign accepted V4", err) + } + if _, err := executeOpsExportSigning(OpsExportSigningOptions{RecordType: "evidence-bundle", RecordPath: o.BundlePath, CeremonyPath: trust.DefinitionPath, CeremonySignaturePath: trust.DefinitionSignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyPath, OutDir: filepath.Join(root, "never-export")}); err == nil || !strings.Contains(err.Error(), "sign-bundle-v4") { + t.Fatal("legacy bundle export accepted V4", err) + } + if _, err := executeOpsImportSignature(OpsImportSignatureOptions{RecordType: "evidence-bundle", CanonicalPath: o.BundlePath, CeremonyPath: trust.DefinitionPath, CeremonySignaturePath: trust.DefinitionSignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyPath, OutPath: filepath.Join(root, "never-import.sig")}); err == nil || !strings.Contains(err.Error(), "sign-bundle-v4") { + t.Fatal("legacy bundle signature import accepted V4", err) + } + o.OutPath = filepath.Join(root, "review-report.json") + writeDecisionTestFile(t, o.BundlePath, append(bytes.Clone(original), '\n'), 0600) + _, rejected = execute(CommandReleaseReviewV4, o) + writeDecisionTestFile(t, o.BundlePath, original, 0600) + if rejected == nil { + t.Fatal("changed signed bundle accepted for review") + } + if _, err := execute(CommandReleaseReviewV4, o); err != nil { + t.Fatal(err) + } + want, err := m.MarshalCanonical(*review) + if err != nil || !bytes.Equal(read(o.OutPath), want) { + t.Fatal("review report differs from verified exact review", err) + } + files, err := filepath.Glob(filepath.Join(root, "checkpoints/*-release.json")) + if err != nil || len(files) != 1 { + t.Fatal("missing exact release checkpoint", files, err) + } + o.CheckpointPath, o.CheckpointSignaturePath = files[0], strings.TrimSuffix(files[0], ".json")+".sig" + o.OutPath = filepath.Join(root, "inventory-report.json") + result, err := execute(CommandCheckpointVerifyReleaseV4, o) + if err != nil { + t.Fatal(err) + } + var inventory ReleaseInventoryReportV4 + if err := m.UnmarshalCanonical(read(o.OutPath), &inventory); err != nil { + t.Fatal(err) + } + if !inventory.ArtifactsVerified || !inventory.CoordinatorReplayClaimBound || inventory.MathematicsReplayed || inventory.GlobalFreshnessVerified || inventory.ProductionAuthorized || inventory.Published || len(inventory.Artifacts) <= 5 || inventory.Release.ReleaseID != result.ReleaseID { + t.Fatal("incorrect verification claims") + } + tampered := bytes.Replace(read(o.OutPath), []byte(`"published":false`), []byte(`"published":true`), 1) + var invalid ReleaseInventoryReportV4 + if err := m.UnmarshalCanonical(tampered, &invalid); err == nil { + t.Fatal("inventory overclaim accepted") + } + // A later released checkpoint is not eligible for preparing another bundle. + bad = o + bad.OutPath = filepath.Join(root, "unused-report.json") + if _, err := execute(CommandReleaseReviewV4, bad); err == nil { + t.Fatal("released head accepted for pre-release review") + } + for _, a := range inventory.Artifacts { + if strings.HasPrefix(a.Name, inventory.PackagePrefix) { + t.Fatal("inventory names not package-relative") + } + } + // A verified metadata chain is insufficient when even one package byte changed. + packageFile := filepath.Join(root, m.FinalReleasePackagePrefixV4, m.NativeVerifyingKeyFile) + saved = read(packageFile) + corrupt := bytes.Clone(saved) + corrupt[len(corrupt)-1] ^= 1 + writeDecisionTestFile(t, packageFile, corrupt, 0600) + o.OutPath = filepath.Join(root, "must-not-exist.json") + _, rejected = execute(CommandCheckpointVerifyReleaseV4, o) + writeDecisionTestFile(t, packageFile, saved, 0600) + if rejected == nil { + t.Fatal("corrupted full package accepted") + } + if _, err := os.Lstat(o.OutPath); !os.IsNotExist(err) { + t.Fatal("failed verification wrote inventory") + } +} diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 29a68e79..846c615f 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -87,6 +87,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeOpsPrepareEnrollment(invocation.Options.(OpsPrepareEnrollmentOptions)) case CommandOpsPrepareBundle: return executeOpsPrepareBundle(invocation.Options.(OpsPrepareBundleOptions)) + case CommandOpsPrepareBundleV4, CommandOpsSignBundleV4, CommandReleaseReviewV4, CommandCheckpointVerifyReleaseV4: + return executeEvidenceV4(invocation.Command, invocation.Options.(EvidenceOptionsV4)) case CommandOpsSign: return executeOpsSign(invocation.Options.(OpsSignOptions)) case CommandOpsImportSig: @@ -101,8 +103,14 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeDecisionVerify(invocation.Options.(DecisionVerifyOptions)) case CommandInspectDefinition: return executeInspectDefinition(invocation.Options.(InspectDefinitionOptions)) + case CommandInspectDefinitionProtocol: + return executeInspectDefinitionProtocol(invocation.Options.(InspectDefinitionOptions)) case CommandInspectChain: return executeInspectChain(invocation.Options.(InspectChainOptions)) + case CommandInspectContributionInventoryV4: + return executeContributionInventoryV4(invocation.Options.(ContributionInventoryOptionsV4)) + case CommandInspectComputationOutputV4: + return executeComputationOutputV4(invocation.Options.(ContributionInventoryOptionsV4)) case CommandInspectParticipant: return executeInspectParticipant(invocation.Options.(InspectParticipantOptions)) case CommandInspectEnrollment: @@ -117,6 +125,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeInspectSubmissionAcknowledgement(invocation.Options.(InspectSubmissionAcknowledgementOptions)) case CommandCheckpointPrepare: return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) + case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointInitializeV4, CommandCheckpointRecordV4, CommandCheckpointAllocateV4, CommandCheckpointAcceptCandidateV4, CommandCheckpointRejectCandidateV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: + return executeCheckpointV4(invocation.Command, invocation.Options.(CheckpointOptionsV4)) case CommandCheckpointSign: return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) case CommandCheckpointVerify: @@ -184,18 +194,19 @@ func executeInit(options InitOptions) (CommandResult, error) { RootDir: options.OutDir, Circuit: circuit, Definition: mpcceremony.DefinitionOptions{ - Mode: options.Mode, - CreatedAt: options.CreatedAt, - SessionNonceHex: nonce, - Software: runningSoftware, - Coordinator: participants.Coordinator, - ReleaseSigner: participants.ReleaseSigner, - Auditors: participants.Auditors, - Roster: participants.Roster, - Phase1Policy: policy.Phase1Policy, - Phase2Policy: policy.Phase2Policy, - BeaconPolicy: policy.BeaconPolicy, - AssurancePolicy: assurancePolicy, + ReleaseVerification: options.ReleaseVerification, + Mode: options.Mode, + CreatedAt: options.CreatedAt, + SessionNonceHex: nonce, + Software: runningSoftware, + Coordinator: participants.Coordinator, + ReleaseSigner: participants.ReleaseSigner, + Auditors: participants.Auditors, + Roster: participants.Roster, + Phase1Policy: policy.Phase1Policy, + Phase2Policy: policy.Phase2Policy, + BeaconPolicy: policy.BeaconPolicy, + AssurancePolicy: assurancePolicy, }, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, }) @@ -226,7 +237,23 @@ func executeContribution(phase mpcceremony.Phase, options ContributeOptions) (Co if err := verifyRunningTrust(trust); err != nil { return CommandResult{}, err } - circuit, err := loadOperationalCircuit(trust, options.TranscriptDir) + trusted, err := mpcceremony.LoadSignedDefinition(trust) + if err != nil { + return CommandResult{}, err + } + circuitRoot := options.TranscriptDir + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + if err := requireValues( + pathValue("--artifact-root", options.ArtifactRoot), + pathValue("--checkpoint", options.CheckpointPath), + pathValue("--checkpoint-signature", options.CheckpointSignaturePath), + value("--attempt-id", options.AttemptID), + ); err != nil { + return CommandResult{}, err + } + circuitRoot = options.ArtifactRoot + } + circuit, err := loadOperationalCircuit(trust, circuitRoot) if err != nil { return CommandResult{}, err } @@ -234,19 +261,33 @@ func executeContribution(phase mpcceremony.Phase, options ContributeOptions) (Co if err != nil { return CommandResult{}, err } - result, err := mpcceremony.CreateContributionCandidate(mpcceremony.ContributionFilesOptions{ - Trust: trust, - Circuit: circuit, - Phase: phase, - Transcript: transcriptPaths(options.TranscriptDir, options.ChainPath, options.ChainSignaturePath), - Phase1SealPath: options.Phase1SealPath, - Phase1SealSignaturePath: options.Phase1SealSignaturePath, - ParticipantID: options.ParticipantID, - ParticipantPrivateKeyPath: options.ParticipantSigningKey, - Environment: environment, - ContributedAt: options.ContributedAt, - CandidateDir: options.OutDir, - }) + var result mpcceremony.ContributionFilesResult + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + _, _, checkpoint, refErr := checkpointSignedBytes(options.ArtifactRoot, options.CheckpointPath, options.CheckpointSignaturePath) + if refErr != nil { + return CommandResult{}, refErr + } + result, err = mpcceremony.CreateAllocatedContributionCandidateV4(mpcceremony.AllocatedContributionFilesV4Options{ + Trust: trust, Circuit: circuit, ArtifactRoot: options.ArtifactRoot, + Checkpoint: checkpoint, AttemptID: options.AttemptID, + ParticipantPrivateKeyPath: options.ParticipantSigningKey, + Environment: environment, ContributedAt: options.ContributedAt, CandidateDir: options.OutDir, + }) + } else { + result, err = mpcceremony.CreateContributionCandidate(mpcceremony.ContributionFilesOptions{ + Trust: trust, + Circuit: circuit, + Phase: phase, + Transcript: transcriptPaths(options.TranscriptDir, options.ChainPath, options.ChainSignaturePath), + Phase1SealPath: options.Phase1SealPath, + Phase1SealSignaturePath: options.Phase1SealSignaturePath, + ParticipantID: options.ParticipantID, + ParticipantPrivateKeyPath: options.ParticipantSigningKey, + Environment: environment, + ContributedAt: options.ContributedAt, + CandidateDir: options.OutDir, + }) + } if err != nil { return CommandResult{}, err } @@ -662,9 +703,19 @@ func executeReleaseSign(options ReleaseSignOptions) (CommandResult, error) { options.CeremonySignaturePath, options.CoordinatorPublicKeyFile, ) - if err := verifyRunningTrust(trust); err != nil { + trusted, err := mpcceremony.LoadSignedDefinition(trust) + if err != nil { return CommandResult{}, err } + if err := mpcceremony.VerifyRunningSoftwareForMode(trusted.Definition.Software, trusted.Definition.Mode); err != nil { + return CommandResult{}, err + } + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return executeReleaseSignV4(options, trust, trusted.Definition.CeremonyID) + } + if options.ReviewCheckpointPath != "" || options.ReviewSignaturePath != "" { + return CommandResult{}, fmt.Errorf("review checkpoint signing requires definition v4") + } coordinatorPublicKey, err := readPublicKeyHex(options.CoordinatorPublicKeyFile) if err != nil { return CommandResult{}, err @@ -720,7 +771,11 @@ func executeReleaseVerify(options ReleaseVerifyOptions) (CommandResult, error) { options.CeremonySignaturePath, options.CoordinatorPublicKeyFile, ) - if err := verifyRunningTrust(trust); err != nil { + trusted, err := mpcceremony.LoadSignedDefinition(trust) + if err != nil { + return CommandResult{}, err + } + if err := mpcceremony.VerifyRunningSoftwareForMode(trusted.Definition.Software, trusted.Definition.Mode); err != nil { return CommandResult{}, err } coordinatorPublicKey, err := readPublicKeyHex(options.CoordinatorPublicKeyFile) @@ -731,6 +786,18 @@ func executeReleaseVerify(options ReleaseVerifyOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + result, err := mpcceremony.VerifyReleaseV4(mpcceremony.VerifyReleaseV4Options{Trust: trust, KeysDir: options.KeysDir, TrustedPublicKeyHex: releasePublicKey, ExpectedSignatureKeyID: options.SignatureKeyID}) + if err != nil { + return CommandResult{}, err + } + if result.Transcript.CeremonyID != trusted.Definition.CeremonyID { + return CommandResult{}, fmt.Errorf("authenticated ceremony changed during release verification") + } + return CommandResult{CeremonyID: result.Transcript.CeremonyID, ReleaseManifestSHA256: result.ManifestSHA256, + Summary: "Verified the local signed package, coordinator replay binding, public proof and required evidence; no publication or production approval occurred.", + Outputs: map[string]string{"keys_dir": options.KeysDir}}, nil + } result, err := mpcceremony.VerifyRelease(mpcceremony.VerifyReleaseOptions{ DefinitionPath: options.CeremonyPath, DefinitionSignaturePath: options.CeremonySignaturePath, diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 92227041..823b6cdd 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -47,8 +47,22 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"decision", "prepare"}, {"decision", "sign"}, {"decision", "verify"}, + {"checkpoint", "prepare-v4"}, + {"checkpoint", "sign-v4"}, + {"checkpoint", "initialize-v4"}, + {"checkpoint", "record-v4"}, + {"checkpoint", "allocate-v4"}, + {"checkpoint", "accept-candidate-v4"}, + {"checkpoint", "verify-stored-v4"}, + {"checkpoint", "inspect-signed-v4"}, + {"checkpoint", "inspect-enrollments-v4"}, + {"checkpoint", "verify-release-v4"}, + {"release", "review-v4"}, + {"ops", "prepare-bundle-v4"}, + {"ops", "sign-bundle-v4"}, {"inspect"}, {"inspect", "definition"}, + {"inspect", "definition-protocol"}, {"inspect", "chain"}, {"inspect", "participant"}, {"inspect", "enrollment"}, @@ -175,8 +189,23 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--release-dir", "--release-signing-key", "--released-at", + "--review-checkpoint", + "--review-checkpoint-signature", + "--reviewed", + "--reviewed-sha256", + "--release-verification", + "--proposal", + "--rejected-candidate-dir", + "--assembled-at", + "--inventory-out", + "--artifact-root", + "--checkpoint", + "--checkpoint-signature", "--record", + "--record-signature", "--record-type", + "--evidence", + "--transition", "--canonical", "--signature", "--signer-public-key-file", @@ -195,6 +224,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--witness-enrollment", "--witness-enrollment-signature", "--accepted-at", + "--allocated-at", + "--attempt-id", "--allowed-binary", "--contributed-at", "--disable-optional-assurance", @@ -238,6 +269,18 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandCheckpointSign, Options: CheckpointSignOptions{}}, {Command: CommandCheckpointVerify, Options: CheckpointVerifyOptions{}}, {Command: CommandCheckpointVerifyStored, Options: CheckpointVerifyStoredOptions{}}, + {Command: CommandCheckpointPrepareV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointSignV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointInitializeV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointRecordV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointVerifyStoredV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointInspectSignedV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointInspectEnrollmentsV4, Options: CheckpointOptionsV4{}}, + {Command: CommandInspectDefinitionProtocol, Options: InspectDefinitionOptions{}}, + {Command: CommandOpsPrepareBundleV4, Options: EvidenceOptionsV4{}}, + {Command: CommandOpsSignBundleV4, Options: EvidenceOptionsV4{}}, + {Command: CommandReleaseReviewV4, Options: EvidenceOptionsV4{}}, + {Command: CommandCheckpointVerifyReleaseV4, Options: EvidenceOptionsV4{}}, } for _, invocation := range tests { t.Run(string(invocation.Command), func(t *testing.T) { @@ -251,6 +294,9 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { commands := [][]string{ + {"inspect", "definition-protocol"}, + {"checkpoint", "inspect-signed-v4"}, + {"checkpoint", "inspect-enrollments-v4"}, {"init"}, {"identity", "generate"}, {"phase1", "contribute"}, diff --git a/cmd/mpc-ceremony/journey_inspection.go b/cmd/mpc-ceremony/journey_inspection.go index cff4620a..8c498617 100644 --- a/cmd/mpc-ceremony/journey_inspection.go +++ b/cmd/mpc-ceremony/journey_inspection.go @@ -48,7 +48,7 @@ type JourneyInspection struct { func inspectDefinitionJourney(d mpcceremony.CeremonyDefinition) *DefinitionJourneyInspection { r := &DefinitionJourneyInspection{Schema: "proof-tool-mpc-definition-journey-v2", MinimumPublicWitnesses: 1, MinimumMirrorsPerAcceptedHead: 1, MinimumPassingCeremonyAudits: 1, MinimumExternalAuditSignoffs: 1, BeaconRoundLeadSeconds: d.BeaconPolicy.MinimumWitnessLeadSeconds, ObserverRequirementSource: "legacy verifier minimums"} - if d.Schema == mpcceremony.DefinitionSchema && d.AssurancePolicy != nil { + if d.UsesSignedAssurancePolicy() && d.AssurancePolicy != nil { r.MinimumPublicWitnesses = int(d.AssurancePolicy.PublicWitnessesPerPhase) r.MinimumMirrorsPerAcceptedHead = int(d.AssurancePolicy.MirrorsPerAcceptedHead) r.MinimumPassingCeremonyAudits = int(d.AssurancePolicy.PassingCeremonyAudits) diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index e7d10be4..975936d0 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -112,6 +112,10 @@ func writeExecutionError(invocation Invocation, err error, args []string, stdout if errors.Is(err, errExecutorNotWired) { code = "engine_not_wired" } + var candidateInvalid interface{ CandidateInvalid() } + if errors.As(err, &candidateInvalid) { + code = "candidate_invalid" + } message := redactCLIError(err.Error(), args) if invocation.Global.Format == "json" { payload := struct { @@ -271,16 +275,16 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}}, + "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "allocate-v4": {}, "accept-candidate-v4": {}, "reject-candidate-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}, "inspect-signed-v4": {}, "inspect-enrollments-v4": {}}, "inspect": { - "chain": {}, "checkpoint": {}, "checkpoint-transition": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, + "chain": {}, "checkpoint": {}, "checkpoint-transition": {}, "definition": {}, "definition-protocol": {}, "enrollment": {}, "help": {}, "participant": {}, }, "ops": { "export-signing": {}, "help": {}, "import-signature": {}, "sign": {}, "prepare-enrollment": {}, "prepare-handoff": {}, "prepare-receipt": {}, - "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "prepare-bundle": {}, "verify": {}, + "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "prepare-bundle": {}, "prepare-bundle-v4": {}, "sign-bundle-v4": {}, "verify": {}, }, "finalize": {"prepare": {}, "complete": {}, "rehearsal-evidence": {}}, - "release": {"help": {}, "sign": {}, "verify": {}}, + "release": {"help": {}, "sign": {}, "verify": {}, "review-v4": {}}, "rehearsal": {"help": {}, "init": {}}, } allowed, hasSubcommands := subcommands[args[index]] @@ -346,7 +350,9 @@ func writeParseError(message string, args []string, stdout, stderr io.Writer) in // redacted, including values following a recognized flag. func markOperationalGrammar(args []string, safe map[int]struct{}) { for index, arg := range args { - if arg == "--related-record" || arg == "--record-type" || arg == "--reviewed-sha256" || arg == "--evidence-root" { + switch arg { + case "--related-record", "--record-type", "--reviewed-sha256", "--evidence-root", + "--release-verification", "--review-checkpoint", "--review-checkpoint-signature", "--proposal", "--rejected-candidate-dir", "--assembled-at", "--inventory-out": safe[index] = struct{}{} } if index > 0 && args[index-1] == "--record-type" { diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index 0c9084dd..c5f05d90 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -185,6 +185,9 @@ func executeOpsExportSigning(options OpsExportSigningOptions) (result CommandRes if err != nil { return CommandResult{}, err } + if recordType == mpcceremony.RecordEvidenceBundle && trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return CommandResult{}, errors.New("definition v4 evidence bundles require ops sign-bundle-v4 with an exact checkpoint pair") + } request, err := mpcceremony.NewOperationalSigningRequest(recordType, canonical) if err != nil { return CommandResult{}, err @@ -261,6 +264,9 @@ func executeOpsImportSignature(options OpsImportSignatureOptions) (CommandResult if err != nil { return CommandResult{}, err } + if recordType == mpcceremony.RecordEvidenceBundle && trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return CommandResult{}, errors.New("definition v4 evidence bundles require ops sign-bundle-v4 with an exact checkpoint pair") + } definitionBytes, err := canonicalDefinition(trusted) if err != nil { return CommandResult{}, err @@ -401,9 +407,13 @@ func executeOpsVerify(options OpsVerifyOptions) (CommandResult, error) { return CommandResult{}, err } } + summary := "verified canonical operational record, ceremony binding, signer identity, and detached signature" + if recordType == mpcceremony.RecordEvidenceBundle && trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + summary += "; bundle evidence checked, but no exact V4 checkpoint equivalence or final release approval was verified" + } return CommandResult{ CeremonyID: trusted.Definition.CeremonyID, - Summary: "verified canonical operational record, ceremony binding, signer identity, and detached signature", + Summary: summary, Outputs: map[string]string{ "record": options.RecordPath, "signature": options.SignaturePath, diff --git a/cmd/mpc-ceremony/ops_bundle.go b/cmd/mpc-ceremony/ops_bundle.go index 06d166b6..b3272214 100644 --- a/cmd/mpc-ceremony/ops_bundle.go +++ b/cmd/mpc-ceremony/ops_bundle.go @@ -33,6 +33,9 @@ func executeOpsPrepareBundle(o OpsPrepareBundleOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return CommandResult{}, errors.New("definition v4 requires ops prepare-bundle-v4 with an exact checkpoint pair") + } prepared, err := mpcceremony.PrepareOperationalEvidence(trusted.Definition, o.EvidenceRoot, time.Now().UTC().Format(time.RFC3339Nano)) if err != nil { return CommandResult{}, err diff --git a/cmd/mpc-ceremony/ops_guided.go b/cmd/mpc-ceremony/ops_guided.go index 2a855f8a..df17f1cd 100644 --- a/cmd/mpc-ceremony/ops_guided.go +++ b/cmd/mpc-ceremony/ops_guided.go @@ -152,6 +152,9 @@ func executeOpsSign(o OpsSignOptions) (CommandResult, error) { return CommandResult{}, errors.New("record changed since owner review") } if bundle, ok := record.(*mpcceremony.OperationalEvidenceBundle); ok { + if trusted.Definition.Schema == mpcceremony.DefinitionSchemaV4 { + return CommandResult{}, errors.New("definition v4 requires ops sign-bundle-v4 with an exact checkpoint pair") + } if err := verifyBundleDraft(trusted, o.EvidenceRoot, canonical, *bundle); err != nil { return CommandResult{}, fmt.Errorf("bundle evidence must verify before accessing the signing key: %w", err) } diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index c2feac32..cf88fc1a 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -207,6 +207,18 @@ func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, e return Invocation{}, &helpRequest{topic: append([]string{"inspect"}, args[1:]...)} } switch args[0] { + case "definition-protocol": + options, err := parseInspectDefinition(args[1:]) + invocation.Command, invocation.Options = CommandInspectDefinitionProtocol, options + return invocation, wrapCommandError(err, "inspect", "definition-protocol") + case "contribution-inventory-v4": + options, err := parseContributionInventoryV4(args[1:]) + invocation.Command, invocation.Options = CommandInspectContributionInventoryV4, options + return invocation, wrapCommandError(err, "inspect", "contribution-inventory-v4") + case "computation-output-v4": + options, err := parseContributionInspectionV4("computation-output-v4", args[1:]) + invocation.Command, invocation.Options = CommandInspectComputationOutputV4, options + return invocation, wrapCommandError(err, "inspect", "computation-output-v4") case "definition": options, err := parseInspectDefinition(args[1:]) invocation.Command, invocation.Options = CommandInspectDefinition, options @@ -465,10 +477,16 @@ func parseDecisionPrepare(args []string) (DecisionPrepareOptions, error) { &options.CoordinatorPublicKeyFile, ) fs.StringVar(&options.DraftPath, "draft", "", "canonical production-decision draft JSON") + fs.StringVar(&options.EvidenceRoot, "evidence-root", "", "required local evidence root for definition v4") fs.StringVar(&options.OutPath, "out", "", "fresh canonical content-addressed decision output") if err := parseFlags(fs, args); err != nil { return options, err } + if options.EvidenceRoot != "" { + if err := validatePathValue("--evidence-root", options.EvidenceRoot); err != nil { + return options, err + } + } return options, requireValues( pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), @@ -563,6 +581,11 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { options, err := parseOpsPrepareBundle(args[1:]) invocation.Command, invocation.Options = CommandOpsPrepareBundle, options return invocation, wrapCommandError(err, "ops", "prepare-bundle") + case "prepare-bundle-v4", "sign-bundle-v4": + invocation.Command = Command("ops " + args[0]) + options, err := parseEvidenceV4(invocation.Command, args[1:]) + invocation.Options = options + return invocation, wrapCommandError(err, "ops", args[0]) case "prepare-public-witness-receipt": options, err := parseOpsPreparePublicWitnessReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPreparePublicWitnessReceipt, options @@ -816,6 +839,10 @@ func parseRelease(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"release"}, args[1:]...)} } switch args[0] { + case "review-v4": + options, err := parseEvidenceV4(CommandReleaseReviewV4, args[1:]) + invocation.Command, invocation.Options = CommandReleaseReviewV4, options + return invocation, wrapCommandError(err, "release", args[0]) case "sign": options, err := parseReleaseSign(args[1:]) invocation.Command, invocation.Options = CommandReleaseSign, options @@ -836,6 +863,7 @@ func parseInit(args []string) (InitOptions, error) { var options InitOptions var allowedBinaries stringList fs := commandFlagSet("init") + fs.StringVar(&options.ReleaseVerification, "release-verification", "", "opt into definition v4 with coordinator-full-replay-v1; omitted preserves v3") 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, or rehearsal-tiny-v1 with --mode rehearsal)") @@ -850,6 +878,9 @@ func parseInit(args []string) (InitOptions, error) { return options, err } options.AllowedBinaryPaths = append([]string(nil), allowedBinaries...) + if options.ReleaseVerification != "" && options.ReleaseVerification != mpcceremony.CoordinatorReplayReleaseV1 { + return options, errors.New("--release-verification must be coordinator-full-replay-v1 or omitted") + } for _, path := range options.AllowedBinaryPaths { if err := validatePathValue("--allowed-binary", path); err != nil { return options, err @@ -902,6 +933,10 @@ func parseContribute(name string, args []string, phase2 bool) (ContributeOptions fs.StringVar(&options.EnvironmentPath, "environment", "", "canonical contribution environment attestation JSON path") fs.StringVar(&options.ContributedAt, "contributed-at", "", "contribution timestamp in RFC3339") fs.StringVar(&options.OutDir, "out-dir", "", "fresh candidate contribution directory") + fs.StringVar(&options.ArtifactRoot, "artifact-root", "", "definition v4 authenticated artifact root") + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "definition v4 signed allocation checkpoint") + fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "definition v4 detached allocation checkpoint signature") + fs.StringVar(&options.AttemptID, "attempt-id", "", "definition v4 preallocated candidate attempt") if err := parseFlags(fs, args); err != nil { return options, err } @@ -1233,6 +1268,8 @@ func parseReleaseSign(args []string) (ReleaseSignOptions, error) { fs := commandFlagSet("release sign") addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) fs.StringVar(&options.CandidateBundleDir, "candidate-bundle", "", "audited candidate key bundle directory") + fs.StringVar(&options.ReviewCheckpointPath, "review-checkpoint", "", "V4 exact signed review checkpoint under operational-evidence-root") + fs.StringVar(&options.ReviewSignaturePath, "review-checkpoint-signature", "", "V4 review checkpoint signature under operational-evidence-root") fs.Var(&auditReports, "audit-report", "independent audit report path; repeat in auditor order") fs.Var(&auditSignatures, "audit-signature", "detached audit signature path; repeat in matching order") fs.StringVar(&options.OperationalEvidenceRoot, "operational-evidence-root", "", "local root containing the complete operational evidence tree") @@ -1252,7 +1289,6 @@ func parseReleaseSign(args []string) (ReleaseSignOptions, error) { pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), - pathValue("--candidate-bundle", options.CandidateBundleDir), pathValue("--operational-evidence-root", options.OperationalEvidenceRoot), pathValue("--operational-bundle", options.OperationalBundlePath), pathValue("--operational-bundle-signature", options.OperationalSignaturePath), @@ -1263,6 +1299,15 @@ func parseReleaseSign(args []string) (ReleaseSignOptions, error) { ); err != nil { return options, err } + if options.ReviewCheckpointPath != "" || options.ReviewSignaturePath != "" { + if err := requireValues(pathValue("--review-checkpoint", options.ReviewCheckpointPath), pathValue("--review-checkpoint-signature", options.ReviewSignaturePath)); err != nil { + return options, err + } + return options, validateReleaseSignShapeV4(options) + } + if err := requireValues(pathValue("--candidate-bundle", options.CandidateBundleDir)); err != nil { + return options, err + } if err := validateAuditArtifacts(options.AuditReportPaths, options.AuditSignaturePaths); err != nil { return options, err } diff --git a/cmd/mpc-ceremony/release_v4.go b/cmd/mpc-ceremony/release_v4.go new file mode 100644 index 00000000..72e0bb21 --- /dev/null +++ b/cmd/mpc-ceremony/release_v4.go @@ -0,0 +1,56 @@ +package main + +import ( + "errors" + "fmt" + + "proof-tool/internal/mpcceremony" +) + +func validateReleaseSignShapeV4(o ReleaseSignOptions) error { + if o.ReviewCheckpointPath == "" || o.ReviewSignaturePath == "" { + return errors.New("definition v4 release signing requires --review-checkpoint and --review-checkpoint-signature") + } + if o.CandidateBundleDir != "" || len(o.AuditReportPaths) != 0 || len(o.AuditSignaturePaths) != 0 || o.Replay != (ReplayOptions{}) { + return errors.New("V4 review signing must not supply legacy candidate, audit or replay flags; the authenticated review determines those inputs") + } + return nil +} + +func executeReleaseSignV4(o ReleaseSignOptions, trust mpcceremony.TrustPaths, ceremonyID string) (CommandResult, error) { + if err := validateReleaseSignShapeV4(o); err != nil { + return CommandResult{}, err + } + at, err := parseUTCTime("--released-at", o.ReleasedAt) + if err != nil { + return CommandResult{}, err + } + // These are bounded metadata, not large contribution files. The helper + // confines both paths to the root and refuses symlink traversal. + _, _, review, err := checkpointSignedBytes(o.OperationalEvidenceRoot, o.ReviewCheckpointPath, o.ReviewSignaturePath) + if err != nil { + return CommandResult{}, fmt.Errorf("review checkpoint: %w", err) + } + _, _, bundle, err := checkpointSignedBytes(o.OperationalEvidenceRoot, o.OperationalBundlePath, o.OperationalSignaturePath) + if err != nil { + return CommandResult{}, fmt.Errorf("operational bundle: %w", err) + } + // Retain the dispatch identity across independently authenticated library reads. + checked, err := mpcceremony.VerifyReleaseReviewV4(trust, o.OperationalEvidenceRoot, review, bundle, at) + if err != nil { + return CommandResult{}, err + } + if checked.CeremonyID != ceremonyID { + return CommandResult{}, errors.New("authenticated ceremony changed during release review") + } + result, err := mpcceremony.SignReleaseV4(mpcceremony.SignReleaseV4Options{Trust: trust, ArtifactRoot: o.OperationalEvidenceRoot, + ReviewCheckpoint: review, OperationalBundle: bundle, ReleaseDir: o.ReleaseDir, ReleaseSigningKey: o.ReleaseSigningKey, + SignatureKeyID: o.SignatureKeyID, ReleasedAt: at}) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: ceremonyID, + Summary: "Created a local signed package after checking the coordinator replay binding, public proof and required evidence. No signer contribution replay, publication or production approval occurred.", + Outputs: map[string]string{"release_dir": o.ReleaseDir, "manifest": result.ManifestPath, "manifest_signature": result.ManifestSignature, + "manifest_public_key": result.ManifestPublicKey, "setup_transcript": result.FinalTranscript, "operational_evidence": result.OperationalEvidence, "checksums": result.ChecksumsPath}}, nil +} diff --git a/cmd/mpc-ceremony/release_v4_test.go b/cmd/mpc-ceremony/release_v4_test.go new file mode 100644 index 00000000..b1a0ecd0 --- /dev/null +++ b/cmd/mpc-ceremony/release_v4_test.go @@ -0,0 +1,145 @@ +package main + +import ( + "os" + "os/exec" + "path/filepath" + "runtime" + "strings" + "testing" + + "proof-tool/internal/mpcceremony" +) + +func TestReleaseV4ExecutableAuthenticatesBeforeDispatch(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("approved executable identity is tested in Linux Docker") + } + root := t.TempDir() + executable := filepath.Join(root, "mpc-ceremony") + build := exec.Command("go", "build", "-o", executable, ".") + if out, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v %s", err, out) + } + software, err := mpcceremony.SoftwareBindingFromExecutableFileForMode(executable, proofToolVersion, mpcceremony.ModeRehearsal) + if err != nil { + t.Fatal(err) + } + d, _, key := decisionSignFixture(t) + d.Mode, d.Software = mpcceremony.ModeRehearsal, software + d.AssurancePolicy.ExternalSecurityAuditSignoffs = 0 + writeDefinition := func(v4 bool) { + t.Helper() + d.Schema, d.ReleaseVerification = mpcceremony.DefinitionSchemaV3, "" + if v4 { + d.Schema, d.ReleaseVerification = mpcceremony.DefinitionSchemaV4, "coordinator-full-replay-v1" + } + d, err = mpcceremony.FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + db, sig, err := mpcceremony.SignRecord(d, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, filepath.Join(root, "ceremony.json"), db, 0o600) + writeDecisionTestFile(t, filepath.Join(root, "ceremony.sig"), sig, 0o600) + } + writeDecisionTestFile(t, filepath.Join(root, "coordinator.hex"), []byte(d.Coordinator.Ed25519PublicKeyHex), 0o600) + writeDecisionTestFile(t, filepath.Join(root, "release.hex"), []byte(d.ReleaseSigner.Ed25519PublicKeyHex), 0o600) + trust := []string{"--ceremony", filepath.Join(root, "ceremony.json"), "--ceremony-signature", filepath.Join(root, "ceremony.sig"), "--coordinator-public-key-file", filepath.Join(root, "coordinator.hex")} + common := append([]string{"release", "sign"}, trust...) + common = append(common, "--operational-evidence-root", root, "--operational-bundle", filepath.Join(root, "bundle.json"), "--operational-bundle-signature", filepath.Join(root, "bundle.sig"), + "--release-signing-key", filepath.Join(root, "MISSING-KEY"), "--signature-key-id", d.ReleaseSigner.KeyID, "--released-at", "2026-09-16T00:00:00Z", "--release-dir", filepath.Join(root, "output")) + v4args := append(append([]string{}, common...), "--review-checkpoint", filepath.Join(root, "head.json"), "--review-checkpoint-signature", filepath.Join(root, "head.sig")) + writeDefinition(false) + assertCheckpointExecutableFails(t, executable, v4args, "requires definition v4") + for _, command := range []Command{CommandOpsPrepareBundleV4, CommandOpsSignBundleV4, CommandReleaseReviewV4, CommandCheckpointVerifyReleaseV4} { + args := evidenceArgsV4(command) + copy(args[:6], trust) + assertCheckpointExecutableFails(t, executable, append(strings.Split(string(command), " "), args...), "require definition v4") + } + legacy := append(append([]string{}, common...), "--candidate-bundle", root) + for _, flag := range []string{"--transcript-root", "--phase1-chain", "--phase1-chain-signature", "--phase1-close", "--phase1-close-signature", "--phase1-beacon", "--phase1-beacon-signature", "--phase1-seal", "--phase1-seal-signature", "--phase2-chain", "--phase2-chain-signature", "--phase2-close", "--phase2-close-signature", "--phase2-beacon", "--phase2-beacon-signature"} { + legacy = append(legacy, flag, root) + } + writeDefinition(true) + assertCheckpointExecutableFails(t, executable, legacy, "requires --review-checkpoint") + verify := append([]string{"release", "verify"}, trust...) + verify = append(verify, "--keys-dir", root, "--manifest-public-key-file", filepath.Join(root, "release.hex"), "--signature-key-id", "wrong-key") + assertCheckpointExecutableFails(t, executable, verify, "release signer id differs from signed definition") + writeDecisionTestFile(t, filepath.Join(root, "ceremony.sig"), []byte("{}"), 0o600) + output, err := exec.Command(executable, v4args...).CombinedOutput() + if err == nil || strings.Contains(string(output), "review checkpoint:") { + t.Fatalf("signature not checked before dispatch: %v %s", err, output) + } + if _, err := os.Stat(filepath.Join(root, "output")); !os.IsNotExist(err) { + t.Fatalf("failed command wrote output: %v", err) + } +} + +func releaseSignV4Args() []string { + return []string{"--ceremony", "ceremony.json", "--ceremony-signature", "ceremony.sig", "--coordinator-public-key-file", "key.hex", + "--operational-evidence-root", "evidence", "--operational-bundle", "evidence/bundle.json", "--operational-bundle-signature", "evidence/bundle.sig", + "--release-signing-key", "private.hex", "--signature-key-id", "release-key", "--released-at", "2026-09-16T00:00:00Z", "--release-dir", "release"} +} + +func TestReleaseV4ParserRequiresPairAndRejectsMixedLegacy(t *testing.T) { + base := releaseSignV4Args() + for _, extra := range [][]string{nil, {"--review-checkpoint", "evidence/head.json"}, {"--review-checkpoint-signature", "evidence/head.sig"}} { + if _, err := parseReleaseSign(append(append([]string{}, base...), extra...)); err == nil { + t.Fatalf("incomplete shape accepted: %v", extra) + } + } + args := append(base, "--review-checkpoint", "evidence/head.json", "--review-checkpoint-signature", "evidence/head.sig") + if _, err := parseReleaseSign(args); err != nil { + t.Fatal(err) + } + for _, flag := range []string{"--candidate-bundle", "--audit-report", "--audit-signature", "--transcript-root", + "--phase1-chain", "--phase1-chain-signature", "--phase1-close", "--phase1-close-signature", "--phase1-beacon", "--phase1-beacon-signature", "--phase1-seal", "--phase1-seal-signature", + "--phase2-chain", "--phase2-chain-signature", "--phase2-close", "--phase2-close-signature", "--phase2-beacon", "--phase2-beacon-signature"} { + t.Run(flag, func(t *testing.T) { + if _, err := parseReleaseSign(append(append([]string{}, args...), flag, "legacy-file")); err == nil || !strings.Contains(err.Error(), "legacy") { + t.Fatalf("mixed shape: %v", err) + } + }) + } +} + +func TestReleaseV4MetadataFailureBeforeKeyOrOutput(t *testing.T) { + root := t.TempDir() + head, sig, bundle, bundleSig := filepath.Join(root, "head.json"), filepath.Join(root, "head.sig"), filepath.Join(root, "bundle.json"), filepath.Join(root, "bundle.sig") + for _, path := range []string{head, sig, bundle, bundleSig} { + writeDecisionTestFile(t, path, []byte("{}"), 0o600) + } + o := ReleaseSignOptions{ReviewCheckpointPath: head, ReviewSignaturePath: sig, OperationalEvidenceRoot: root, + OperationalBundlePath: bundle, OperationalSignaturePath: bundleSig, ReleaseSigningKey: filepath.Join(root, "MISSING-KEY"), + ReleasedAt: "2026-09-16T00:00:00Z", ReleaseDir: filepath.Join(root, "output")} + for _, tc := range []struct { + name, path string + size int64 + }{ + {"checkpoint", head, maxOperationalRecordBytes + 1}, {"signature", sig, 4097}, {"bundle", bundle, maxOperationalRecordBytes + 1}, {"bundle signature", bundleSig, 4097}, + } { + t.Run(tc.name, func(t *testing.T) { + f, err := os.OpenFile(tc.path, os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(tc.size); err != nil { + t.Fatal(err) + } + if err := f.Close(); err != nil { + t.Fatal(err) + } + _, err = executeReleaseSignV4(o, mpcceremony.TrustPaths{}, "unused") + if err == nil || strings.Contains(err.Error(), "MISSING-KEY") { + t.Fatalf("metadata not rejected before key: %v", err) + } + if _, err := os.Stat(o.ReleaseDir); !os.IsNotExist(err) { + t.Fatalf("unexpected output: %v", err) + } + writeDecisionTestFile(t, tc.path, []byte("{}"), 0o600) + }) + } +} diff --git a/cmd/mpc-ceremony/secret_boundary_test.go b/cmd/mpc-ceremony/secret_boundary_test.go index 78dee78e..939b6748 100644 --- a/cmd/mpc-ceremony/secret_boundary_test.go +++ b/cmd/mpc-ceremony/secret_boundary_test.go @@ -45,7 +45,10 @@ func TestProductionCeremonySourceAndBinaryExcludeWalletSecretAPIs(t *testing.T) } binary := filepath.Join(t.TempDir(), "mpc-ceremony") - build := exec.Command("go", "build", "-mod=vendor", "-trimpath", "-o", binary, "./cmd/mpc-ceremony") + // This test inspects the compiled binary's secret boundary; it is not a + // vendoring check. Use module mode so the assertion remains portable when a + // source export intentionally omits a synchronized vendor tree. + build := exec.Command("go", "build", "-mod=mod", "-trimpath", "-o", binary, "./cmd/mpc-ceremony") build.Dir = root build.Env = append(os.Environ(), "GOWORK=off") if output, err := build.CombinedOutput(); err != nil { diff --git a/cmd/mpc-ceremony/submission_rename_darwin.go b/cmd/mpc-ceremony/submission_rename_darwin.go new file mode 100644 index 00000000..04edba13 --- /dev/null +++ b/cmd/mpc-ceremony/submission_rename_darwin.go @@ -0,0 +1,12 @@ +//go:build darwin + +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "golang.org/x/sys/unix" + +func renameDirectoryNoReplace(oldPath, newPath string) error { + return unix.RenameatxNp(unix.AT_FDCWD, oldPath, unix.AT_FDCWD, newPath, unix.RENAME_EXCL) +} diff --git a/cmd/mpc-ceremony/submission_rename_linux.go b/cmd/mpc-ceremony/submission_rename_linux.go new file mode 100644 index 00000000..6e1bcde2 --- /dev/null +++ b/cmd/mpc-ceremony/submission_rename_linux.go @@ -0,0 +1,12 @@ +//go:build linux + +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "golang.org/x/sys/unix" + +func renameDirectoryNoReplace(oldPath, newPath string) error { + return unix.Renameat2(unix.AT_FDCWD, oldPath, unix.AT_FDCWD, newPath, unix.RENAME_NOREPLACE) +} diff --git a/cmd/mpc-ceremony/submission_rename_other.go b/cmd/mpc-ceremony/submission_rename_other.go new file mode 100644 index 00000000..843c96ed --- /dev/null +++ b/cmd/mpc-ceremony/submission_rename_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin + +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import "errors" + +func renameDirectoryNoReplace(_, _ string) error { + return errors.New("atomic no-replace submission publication requires Linux or macOS") +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index b82e65ca..3b93cd9e 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -39,11 +39,14 @@ const ( CommandReplay Command = "replay" CommandReleaseSign Command = "release sign" CommandReleaseVerify Command = "release verify" + CommandReleaseReviewV4 Command = "release review-v4" CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" CommandOpsExportSigning Command = "ops export-signing" CommandOpsPrepareEnrollment Command = "ops prepare-enrollment" CommandOpsPrepareBundle Command = "ops prepare-bundle" + CommandOpsPrepareBundleV4 Command = "ops prepare-bundle-v4" + CommandOpsSignBundleV4 Command = "ops sign-bundle-v4" CommandOpsSign Command = "ops sign" CommandOpsImportSig Command = "ops import-signature" CommandOpsVerify Command = "ops verify" @@ -51,7 +54,10 @@ const ( CommandDecisionSign Command = "decision sign" CommandDecisionVerify Command = "decision verify" CommandInspectDefinition Command = "inspect definition" + CommandInspectDefinitionProtocol Command = "inspect definition-protocol" CommandInspectChain Command = "inspect chain" + CommandInspectContributionInventoryV4 Command = "inspect contribution-inventory-v4" + CommandInspectComputationOutputV4 Command = "inspect computation-output-v4" CommandInspectParticipant Command = "inspect participant" CommandInspectEnrollment Command = "inspect enrollment" CommandInspectCheckpoint Command = "inspect checkpoint" @@ -62,6 +68,17 @@ const ( CommandCheckpointSign Command = "checkpoint sign" CommandCheckpointVerify Command = "checkpoint verify" CommandCheckpointVerifyStored Command = "checkpoint verify-stored" + CommandCheckpointPrepareV4 Command = "checkpoint prepare-v4" + CommandCheckpointSignV4 Command = "checkpoint sign-v4" + CommandCheckpointInitializeV4 Command = "checkpoint initialize-v4" + CommandCheckpointRecordV4 Command = "checkpoint record-v4" + CommandCheckpointAllocateV4 Command = "checkpoint allocate-v4" + CommandCheckpointAcceptCandidateV4 Command = "checkpoint accept-candidate-v4" + CommandCheckpointRejectCandidateV4 Command = "checkpoint reject-candidate-v4" + CommandCheckpointVerifyStoredV4 Command = "checkpoint verify-stored-v4" + CommandCheckpointInspectSignedV4 Command = "checkpoint inspect-signed-v4" + CommandCheckpointInspectEnrollmentsV4 Command = "checkpoint inspect-enrollments-v4" + CommandCheckpointVerifyReleaseV4 Command = "checkpoint verify-release-v4" ) type GlobalOptions struct { @@ -83,6 +100,7 @@ type IdentityGenerateOptions struct { } type InitOptions struct { + ReleaseVerification string SessionNonceHex string CreatedAt string KeyVersion string @@ -117,6 +135,10 @@ type ContributeOptions struct { EnvironmentPath string ContributedAt string OutDir string + ArtifactRoot string + CheckpointPath string + CheckpointSignaturePath string + AttemptID string } type VerifyContributionOptions struct { @@ -232,6 +254,8 @@ type ReleaseSignOptions struct { CeremonySignaturePath string CoordinatorPublicKeyFile string CandidateBundleDir string + ReviewCheckpointPath string + ReviewSignaturePath string AuditReportPaths []string AuditSignaturePaths []string OperationalEvidenceRoot string @@ -393,6 +417,9 @@ type CheckpointEvidenceOptions struct { NextManifestKey string CandidateDir string ReleaseDir string + AcknowledgementRecordName string + AcknowledgementSignatureName string + AcceptanceSigner checkpointAcceptanceSigner } type CheckpointPrepareOptions struct { @@ -560,6 +587,7 @@ type DecisionPrepareOptions struct { CeremonySignaturePath string CoordinatorPublicKeyFile string DraftPath string + EvidenceRoot string OutPath string } @@ -611,12 +639,19 @@ type CommandResult struct { Summary string `json:"summary,omitempty"` Identity *mpcceremony.Identity `json:"identity,omitempty"` DefinitionInspection *DefinitionInspection `json:"definition_inspection,omitempty"` + DefinitionProtocolInspection *DefinitionProtocolInspection `json:"definition_protocol_inspection,omitempty"` + ContributionInventoryV4 *ContributionInventoryInspectionV4 `json:"contribution_inventory_v4,omitempty"` + ComputationOutputV4 *ComputationOutputInspectionV4 `json:"computation_output_v4,omitempty"` + CheckpointDiscoveryV4 *CheckpointDiscoveryInspectionV4 `json:"checkpoint_discovery_v4,omitempty"` + EnrollmentMetadataV4 *EnrollmentMetadataInspectionV4 `json:"enrollment_metadata_v4,omitempty"` ChainInspection *ChainInspection `json:"chain_inspection,omitempty"` ParticipantInspection *ParticipantInspection `json:"participant_inspection,omitempty"` EnrollmentInspection *EnrollmentInspection `json:"enrollment_inspection,omitempty"` CheckpointInspection *CheckpointInspection `json:"checkpoint_inspection,omitempty"` CheckpointTransitionInspection *CheckpointTransitionInspection `json:"checkpoint_transition_inspection,omitempty"` CheckpointEvidenceInspection *CheckpointEvidenceInspection `json:"checkpoint_evidence_inspection,omitempty"` + CheckpointInspectionV4 *CheckpointInspectionV4 `json:"checkpoint_inspection_v4,omitempty"` + EvidenceInspectionV4 *EvidenceInspectionV4 `json:"evidence_inspection_v4,omitempty"` SubmissionInspection *SubmissionInspection `json:"submission_inspection,omitempty"` SubmissionAcknowledgementInspection *SubmissionAcknowledgementInspection `json:"submission_acknowledgement_inspection,omitempty"` JourneyInspection *JourneyInspection `json:"journey_inspection,omitempty"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index b0f117c2..31c04c97 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -61,7 +61,10 @@ Commands: checkpoint verify Fully verify a signed ceremony checkpoint and its evidence checkpoint verify-stored Infer and fully verify a fetched checkpoint ancestry inspect definition Authenticate and describe a ceremony definition + inspect definition-protocol Authenticate its protocol selector and schedules inspect chain Authenticate and describe an accepted chain + inspect contribution-inventory-v4 Reconstruct retained V4 candidate files + inspect computation-output-v4 Check generated files before cleanup signing inspect participant Match an existing key to the participant roster inspect enrollment Authenticate an operational enrollment inspect checkpoint Authenticate a storage-first workflow checkpoint @@ -180,6 +183,14 @@ Authenticated record projections are also available as subcommands: These subcommands are read-only and machine-readable. They perform no network access, replay, signing, or writes. +`, + "inspect definition-protocol": `Usage: + mpc-ceremony --format json inspect definition-protocol --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY + +Authenticates the definition before reporting its exact format, derived storage +workflow, release verification policy and schedules. This does not authenticate +backend progress or replay contributions. Failure must not trigger legacy fallback. `, "inspect definition": `Usage: mpc-ceremony --format json inspect definition --ceremony FILE \ @@ -187,6 +198,33 @@ access, replay, signing, or writes. Authenticates the exact canonical ceremony definition against the out-of-band coordinator public key and reports its identity, mode, schedules, and circuit. +`, + "inspect computation-output-v4": `Usage: + mpc-ceremony --format json inspect computation-output-v4 --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --transcript-root DIR --chain FILE --chain-signature FILE \ + --scope FILE --candidate-dir DIR + +Read-only. Checks attestation.json, attestation.sig and contribution.bin against +the exact signed predecessor and canonical expected scope. No candidate inventory +ID is returned. Extra files, including cleanup records, are not inspected here. +Does not verify cleanup, process exit, mathematics, acceptance, freshness or +physical erasure. The controller must check container absence separately before +using this result to finish an interrupted computation operation. +`, + "inspect contribution-inventory-v4": `Usage: + mpc-ceremony --format json inspect contribution-inventory-v4 --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --transcript-root DIR --chain FILE --chain-signature FILE \ + --scope FILE --candidate-dir DIR + +Read-only. The canonical scope must come from authenticated ceremony state or +the exact retained operation. Verifies the signed predecessor, participant +signatures, cleanup claim and streamed payload digest. Returns the five-file +inventory and, when complete, the seven-file signed return inventory. A partial +return pair is an error. Other local files are ignored, not approved for upload. +Does not verify mathematics, acceptance, freshness or physical erasure. Recheck +returned digests when uploading; paths are not frozen by this inspection. `, "inspect chain": `Usage: mpc-ceremony --format json inspect chain --ceremony FILE \ @@ -237,16 +275,153 @@ Authenticates both exact signed checkpoints, verifies that the child binds the exact parent record and detached signature, and enforces the legal structural transition. It does not fetch or replay the protocol artifacts referenced by that transition. +`, + "checkpoint prepare-v4": `Usage: + mpc-ceremony checkpoint prepare-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR --proposal FILE \ + [--rejected-candidate-dir DIR] --out FRESH_FILE + +Checks the exact canonical V4 proposal and its required evidence. Mathematical +transitions use the authenticated stored circuit. The output is an unsigned +checked draft, not published state. Existing files are never overwritten. +The private rejected-candidate directory is required only for a rejection. +Keep it separate from the public artifact root. Proposal/output files must stay +outside the closed final candidate, release, and rejected-candidate directories. +`, + "checkpoint sign-v4": `Usage: + mpc-ceremony checkpoint sign-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR --proposal FILE \ + [--rejected-candidate-dir DIR] --coordinator-signing-key KEY --out FRESH_FILE + +Repeats all proposal checks before loading the coordinator key and signs the +exact checked bytes. Keep the proposal and detached signature together. Neither +is the published current head until the delivery service uploads both and +successfully updates the head. Existing outputs require inspection, not overwrite. +`, + "checkpoint allocate-v4": `Usage: + mpc-ceremony checkpoint allocate-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE --attempt-id HEX \ + --allocated-at RFC3339 --coordinator-signing-key KEY --out-dir FRESH_DIR + +Authenticates the complete retained checkpoint ancestry, derives the exact next +phase, participant, index and input head from signed ceremony state, and creates +a signed candidate-allocation checkpoint. The caller cannot override the turn. +The output is not current until the delivery service uploads the pair and +conditionally advances the ceremony head. +The output directory must be fresh and its parent must already exist. +`, + "checkpoint initialize-v4": `Usage: + mpc-ceremony checkpoint initialize-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --coordinator-signing-key KEY --out-dir FRESH_DIR + +Authenticates the signed V4 definition and stored circuit, fully checks the +Phase 1 genesis chain and derives the only valid sequence-zero checkpoint. +Creates a signed pair atomically. The pair is not current until the delivery +service publishes its immutable files and creates the ceremony root. +The output directory must be fresh and its parent must already exist. +`, + "checkpoint record-v4": `Usage: + mpc-ceremony checkpoint record-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE --transition KIND \ + --record FILE --record-signature FILE [--evidence FILE ...] \ + --coordinator-signing-key KEY --out-dir FRESH_DIR + +Authenticates the complete current V4 state and the supplied signed protocol +record, derives the only legal descendant and signs it atomically. This covers +enrollment, optional assurance evidence, phase closure/beacon/seal/init, final +candidate review, final release, incidents and aborts. Allocation and candidate +acceptance use their dedicated commands. The output is not current until the +delivery service conditionally publishes it. +The output directory must be fresh and its parent must already exist. +`, + "checkpoint accept-candidate-v4": `Usage: + mpc-ceremony checkpoint accept-candidate-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE --attempt-id HEX \ + --candidate-dir DIR --accepted-at RFC3339 \ + --coordinator-signing-key KEY --out-dir FRESH_DIR + +Authenticates the active allocation, independently verifies the exact candidate +and contribution mathematics against its immutable input snapshot, writes the +accepted transcript artifacts, then creates the signed descendant checkpoint. +The output is not current until the delivery service conditionally advances the +ceremony head. No participant transport envelope or custody receipt is used. +The output directory must be fresh and its parent must already exist. +`, + "checkpoint reject-candidate-v4": `Usage: + mpc-ceremony checkpoint reject-candidate-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE --attempt-id HEX \ + --rejected-candidate-dir PRIVATE_DIR --coordinator-signing-key KEY --out-dir FRESH_DIR + +Authenticates the active allocation and records hashes of exactly the five +private candidate files without accepting their signatures, cleanup claim, or +contribution mathematics. It retires this allocation without creating a +replacement. A later allocation requires a fresh contribution in a new +directory. The output is not current until the delivery service conditionally +advances the ceremony head. The output directory must be fresh and its parent +must already exist. +`, + "checkpoint inspect-enrollments-v4": `Usage: + mpc-ceremony checkpoint inspect-enrollments-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE + +Verifies signed ancestry and the exact committed enrollment record/signature +set in one pass. Returns separately labelled structural commitments and +authenticated identities bound to this head. Does not read +disclosure contents, prove independent operators, or check roster completeness. +No signing, contribution replay, network access or writes occur. +`, + "checkpoint inspect-signed-v4": `Usage: + mpc-ceremony checkpoint inspect-signed-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE + +Authenticates only this checkpoint pair for discovery of its predecessor and +bounded verification dependencies. It does not load ancestry or contribution +files. Run verify-stored-v4 on the complete ancestry before using its progress. +`, + "checkpoint verify-stored-v4": `Usage: + mpc-ceremony checkpoint verify-stored-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE + +Authenticates retained checkpoint ancestry and legal metadata transitions. +This does not verify every referenced artifact, the final release package, +contribution mathematics, or whether a newer head exists on the delivery service. +`, + "checkpoint verify-release-v4": `Usage: + mpc-ceremony checkpoint verify-release-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE --inventory-out FRESH_FILE + +Verifies the exact final-release checkpoint and complete private package, including +required evidence and the coordinator replay binding. The bounded inventory is an +unsigned local report with package-relative names, not a trusted download list. +No contribution replay, global freshness, production approval or publication is +performed. Keep the report outside final/candidate and final/release. `, "checkpoint": `Usage: mpc-ceremony checkpoint [flags] + mpc-ceremony checkpoint [flags] + mpc-ceremony checkpoint [flags] + mpc-ceremony checkpoint inspect-signed-v4 [flags] + mpc-ceremony checkpoint inspect-enrollments-v4 [flags] -Guarded storage-first checkpoint operations. Every operation re-authenticates +Legacy storage-first checkpoint operations re-authenticate the exact signed definition, predecessor, both phase chains and all records that cause the transition. The authenticated lifecycle runs from initialization through both phases, the fully replayed final candidate, and the exact signed release tree. Candidate acceptance and finalization replay the contribution mathematics and cleanup evidence. +The explicit V4 commands use candidate allocations and coordinator acceptance +rather than participant transport envelopes. prepare-v4 and sign-v4 verify required transition evidence; +verify-stored-v4 checks signed ancestry/metadata only, not all artifact bytes, +mathematics or freshness. See each command's help for its exact boundary. `, "checkpoint prepare": `Usage: mpc-ceremony checkpoint prepare --ceremony FILE --ceremony-signature FILE \ @@ -322,7 +497,8 @@ fully_verified=true. Structural inspect output is diagnostics-only. --participants ROSTER.json --policy POLICY.json \ --coordinator-key-id ID --coordinator-signing-key KEY \ --created-at RFC3339 --out-dir DIR [--mode rehearsal|production] \ - [--session-nonce-hex HEX] [--allowed-binary FILE ...] + [--session-nonce-hex HEX] [--allowed-binary FILE ...] \ + [--release-verification coordinator-full-replay-v1] Compiles a registered repository circuit and writes a fresh signed ceremony definition. The authoritative ceremony ID is derived from canonical content, @@ -330,6 +506,10 @@ including a 32-byte session nonce securely generated when omitted. Production mode requires exact clean source builds. The running binary is always allowed; each repeated --allowed-binary adds one authenticated binary for another platform to the signed definition. +Omitting --release-verification preserves Definition V3. The explicit value +opts a fresh ceremony into Definition V4: coordinator full replay remains +mandatory; the required release signer verifies its exact binding without a +second contribution replay. This never upgrades an existing ceremony. `, "phase1": `Usage: mpc-ceremony phase1 [flags] @@ -342,10 +522,14 @@ the exact accepted chain; the command never discovers a "latest" state. --ceremony-signature FILE --coordinator-public-key-file KEY \ --transcript-dir DIR --chain FILE --chain-signature FILE \ --participant-id ID --participant-signing-key KEY \ - --environment FILE --contributed-at RFC3339 --out-dir FRESH_DIR + --environment FILE --contributed-at RFC3339 --out-dir FRESH_DIR \ + [--artifact-root DIR --checkpoint FILE --checkpoint-signature FILE \ + --attempt-id HEX] Replays the complete accepted phase 1 chain before adding OS-generated -randomness. The input chain is never modified. +randomness. The input chain is never modified. Definition V4 requires the four +allocation flags; it derives and rechecks the exact input snapshot from that +signed checkpoint in this same process before generating randomness. `, "phase1 attest-erasure": `Usage: mpc-ceremony phase1 attest-erasure --ceremony FILE \ @@ -424,7 +608,12 @@ Phase 2 is bound to the exact compiled R1CS and verified phase 1 seal. --phase1-seal FILE --phase1-seal-signature FILE \ --transcript-dir DIR --chain FILE --participant-id ID \ --chain-signature FILE --participant-signing-key KEY \ - --environment FILE --contributed-at RFC3339 --out-dir FRESH_DIR + --environment FILE --contributed-at RFC3339 --out-dir FRESH_DIR \ + [--artifact-root DIR --checkpoint FILE --checkpoint-signature FILE \ + --attempt-id HEX] + +Definition V4 requires the four allocation flags and derives the exact Phase 2 +chain and Phase 1 seal from the authenticated checkpoint before randomness. `, "phase2 attest-erasure": `Usage: mpc-ceremony phase2 attest-erasure --ceremony FILE \ @@ -533,9 +722,23 @@ two-phase replay. It emits a signed passing record only after reproducing the candidate's native keys, Cardano export, and coherence evidence. `, "release": `Usage: - mpc-ceremony release [flags] + mpc-ceremony release [flags] Release authenticity is separate from MPC contribution identity. +`, + "release review-v4": `Usage: + mpc-ceremony release review-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE \ + --operational-bundle DIR/operational/evidence-bundle.json \ + --operational-bundle-signature DIR/operational/evidence-bundle.sig \ + --released-at RFC3339_UTC --out FRESH_FILE + +Checks the exact unreleased checkpoint, candidate, bundle, required evidence and +coordinator replay binding. Writes an unsigned bounded local review report; +release sign does not accept this report as input and recomputes the review. +No contribution replay or publication occurs. Keep the report outside the +closed final/candidate and final/release directories. Parent must exist. `, "release sign": `Usage: mpc-ceremony release sign --ceremony FILE --ceremony-signature FILE \ @@ -550,11 +753,22 @@ Release authenticity is separate from MPC contribution identity. Requires at least the signed minimum number of passing ceremony audits assurance policy, plus the coordinator-signed Phase 1 and Phase 2 operational - bundle. Witness and mirror evidence likewise follows that signed policy; - multi-relay beacon evidence remains required. The candidate is - never mutated; all verified evidence is atomically published into a fresh - release directory. For current ceremonies, the release signer independently - replays both phases even when the signed audit minimum is zero. + bundle. Witness and mirror evidence likewise follows that signed policy. + Definitions V1-V3 retain their multi-relay beacon-evidence requirement. The candidate is + never mutated; all verified evidence is assembled into a fresh local + release directory. Definition V3 requires independent signer replay of both + phases even when the signed audit minimum is zero. + +For Definition V4, replace --candidate-bundle, audit and replay flags with: + --review-checkpoint FILE --review-checkpoint-signature FILE +Both files and the operational bundle pair must be under --operational-evidence-root. +The signed review determines the candidate and required audits. The signer checks +the exact coordinator replay binding, public proof, key exports and required +evidence, including each phase's signed beacon and raw verified response, +without replaying contributions or requiring a separate multi-relay record. +The approved executable is checked +before loading the release key. The output must be outside the evidence root. +This creates a local signed package, not a storage publication or production GO. `, "release verify": `Usage: mpc-ceremony release verify --ceremony FILE --ceremony-signature FILE \ @@ -564,6 +778,8 @@ Release authenticity is separate from MPC contribution identity. Authenticates the release using the out-of-band release public key, then strictly verifies the bundled audit evidence, transcript, native keys, Cardano export, candidate signature, and checksums. +Definition V4 selects the new exact review/package verifier automatically after +authenticating the definition. This does not publish or approve production use. `, "decision": `Usage: mpc-ceremony decision [flags] @@ -574,13 +790,17 @@ entropy quality, erasure, public witnessing, mirrors, or attendance. `, "decision prepare": `Usage: mpc-ceremony decision prepare --ceremony FILE --ceremony-signature FILE \ - --coordinator-public-key-file KEY --draft FILE --out FRESH_FILE + --coordinator-public-key-file KEY --draft FILE --out FRESH_FILE \ + [--evidence-root DIR] Strictly parses a production decision draft matching the authenticated ceremony schema, derives the release_id and decision_id, and checks ceremony, source, exact K=21 circuit, and signer-role bindings. The fresh output is the only byte string the accountable roles should sign. +Definition V4 requires --evidence-root and verifies its complete local release +package and decision evidence before writing. Keep --out outside final/release. +Older definitions do not accept this preparation flag. `, "decision sign": `Usage: mpc-ceremony decision sign --ceremony FILE --ceremony-signature FILE \ @@ -594,9 +814,10 @@ 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 -evidence. +evidence set. Definition V4 requires verified evidence for both GO and post-package +NO-GO; use the authenticated abort procedure for an earlier stop without a package. +Keep --out outside final/release. Older definitions retain optional evidence +verification for NO-GO records reporting unavailable evidence. `, "decision verify": `Usage: mpc-ceremony decision verify --ceremony FILE --ceremony-signature FILE \ @@ -606,15 +827,22 @@ evidence. Strictly parses the record and detached role signatures, hashes every local evidence artifact, checks release/candidate/transcript/operational/audit -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. +coherence. GO requires every applicable gate to PASS and signatures from the +coordinator, release signer and every required ceremony auditor. Disabled optional +gates must explicitly be NOT_REQUIRED. V4 evidence uses local logical names; +legacy evidence URIs are content bindings only. No network fetch or publication +occurs. Verification of external reports binds reviewed claims, not independent +proof that the reported real-world actions happened. `, "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. +receipts, immutable mirrors, pre-beacon public witnesses, legacy multi-operator +relay evidence, governance events, and the release-bound operational evidence +bundle. V4 binds the already signed beacon record and its one verified raw +response instead of adding a separate relay-evidence record. `, "ops prepare-public-witness-receipt": `Usage: mpc-ceremony ops prepare-public-witness-receipt \ @@ -696,6 +924,31 @@ The reviewed hash binds signing to bytes previously shown by a helper. It is required for handoff, receipt, beacon-evidence and evidence-bundle signing. Run ops verify afterwards; receipts require --related-record and bundles require --evidence-root. A signature alone does not verify a complete ceremony. +Definition V4 evidence bundles require ops sign-bundle-v4 instead. +`, + "ops prepare-bundle-v4": `Usage: + mpc-ceremony ops prepare-bundle-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE --assembled-at RFC3339_UTC \ + --out DIR/operational/evidence-bundle.json + +Derives an unsigned bundle only from this exact authenticated V4 checkpoint and +verifies its required operational evidence. The operational directory must exist +and be real; output must be fresh. Keep the exact checkpoint pair for signing. +No contribution replay, release approval or publication occurs. +`, + "ops sign-bundle-v4": `Usage: + mpc-ceremony ops sign-bundle-v4 --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE \ + --operational-bundle DIR/operational/evidence-bundle.json \ + --coordinator-signing-key KEY --reviewed --reviewed-sha256 HEX \ + --out DIR/operational/evidence-bundle.sig + +Review the canonical bundle first. Before loading the coordinator key, rederives +the bundle against this exact checkpoint and its saved assembly time, and requires +identical reviewed bytes. The signature output must be fresh in the existing real +operational directory. This is not final release approval or global freshness. `, "ops prepare-bundle": `Usage: mpc-ceremony ops prepare-bundle --ceremony FILE --ceremony-signature FILE \ @@ -714,6 +967,7 @@ If complete, independently verifies all referenced evidence and exports an UNSIGNED canonical bundle and signing request. It does not invent records, backdate observations, or sign for other roles. Release still requires the coordinator's bundle signature and successful signed-bundle verification. +Definition V4 requires ops prepare-bundle-v4 with an exact checkpoint instead. `, "ops export-signing": `Usage: mpc-ceremony ops export-signing --record-type TYPE --record FILE \ @@ -722,6 +976,8 @@ coordinator's bundle signature and successful signed-bundle verification. Strictly verifies the canonical record and ceremony binding, then exports canonical.json and signing-request.json. No private signing key is read. +Definition V4 evidence bundles must use ops sign-bundle-v4; this legacy export +does not bind an exact checkpoint. `, "ops import-signature": `Usage: mpc-ceremony ops import-signature --record-type TYPE --canonical FILE \ @@ -732,6 +988,7 @@ canonical.json and signing-request.json. No private signing key is read. Accepts 64 raw signature bytes or 128 lowercase hex characters, verifies the offline Ed25519 signature over exact canonical bytes and signer identity, then writes the repository detached-signature format without replacement. +Definition V4 evidence bundles require ops sign-bundle-v4 with an exact checkpoint. `, "ops verify": `Usage: mpc-ceremony ops verify --record-type TYPE --record FILE --signature FILE \ @@ -742,7 +999,9 @@ writes the repository detached-signature format without replacement. Authenticates canonical bytes, immutable ceremony fields, enrolled signer, and detached signature. Receipt verification requires the exact related handoff. Evidence-bundle verification requires the complete local evidence root and -validates both authenticated chains, every custody transfer, independent -mirrors and public witnesses, and at least two distinct beacon relay operators. +validates both authenticated chains, required custody, mirror and witness records, +and the required beacon relay evidence. It does not prove independent operators. +For V4 it does not establish equivalence to a particular checkpoint or authorize +release; use release review-v4 for the exact pre-release review. `, } diff --git a/docs/ceremony-custody-workflow.md b/docs/ceremony-custody-workflow.md index 7e09a009..f80d972a 100644 --- a/docs/ceremony-custody-workflow.md +++ b/docs/ceremony-custody-workflow.md @@ -1,6 +1,11 @@ -# Supported custody and tiny-proof commands +# Legacy V1–V3 custody and tiny-proof commands -These commands close the gaps found in the September 2026 same-operator rehearsal. +These commands apply to ceremonies whose signed definition selects the released +V1–V3 workflow. Definition V4 replaces per-turn custody packets with a signed +candidate allocation and coordinator acceptance checkpoint; do not add legacy +custody records to a V4 turn. + +The legacy commands close the gaps found in the September 2026 same-operator rehearsal. They do not change the signed protocol, waive operational evidence, prove physical independence, or authorize a production release. diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md new file mode 100644 index 00000000..87b3cdf4 --- /dev/null +++ b/docs/ceremony-schema-compatibility.md @@ -0,0 +1,103 @@ +# Ceremony schema compatibility + +## Released formats are frozen + +Verified against released commit `47bec5663d04a4f8ac330fc38f126e6e7c1140f1` +(PR #31, September 15, 2026). Existing ceremonies keep their signed definition, +software allowlist and verification rules. + +| Boundary | Released versions | Preserved meaning | +| --- | --- | --- | +| Ceremony definition | V1, V2, V3 | V1 single binary; V2 binary allowlist; V3 explicit assurance policy | +| Checkpoint | V1, V2, V3 | Existing storage-first lifecycle and custody/submission records | +| Operational bundle | V2, V3 | Existing custody, cleanup and enabled-assurance evidence | +| Final candidate/transcript | candidate V2; transcript V1, V2 | Existing coordinator replay and signer-replay requirements | +| Release manifest | key manifest V1 | Existing application bundle and setup-transcript binding | +| Production decision | V1, V2 | Existing evidence gates and signed decision meaning | + +Coordinator `PrepareFinalization` and `Finalize` have required a complete +coordinator replay since commit `c1f177ee486fd555fac0dc4d9812b86737fccdfd` +(July 31, 2026). PR #31 additionally required the release signer to repeat that +replay for Definition V3. Neither rule may be weakened for V1–V3. + +## Definition V4 is a replacement protocol + +V4 is selected only by an explicitly signed V4 definition. It never upgrades an +existing ceremony and never changes the interpretation of a released record. + +Its trust model is deliberately simple: + +- the coordinator is trusted to choose legal ceremony actions and perform the + mandatory full mathematical replay; +- the delivery service is trusted for availability and transport, while signed + hashes still detect accidental or unauthorized byte changes; +- participants are trusted to follow the cleanup procedure they attest to; +- the release signer remains a required distinct signing role, but need not + repeat the coordinator's mathematics; +- witnesses, mirrors, ceremony auditors and external audit signoffs are enabled + only when their signed policy count is nonzero. + +The normal participant turn is: + +1. The coordinator signs a candidate allocation derived from the authenticated + current checkpoint. Phase, index, participant and parent head are not caller + choices. +2. The participant authenticates that allocation and its exact input snapshot in + the same proof-tool process that generates contribution randomness. +3. The participant contributes, confirms cleanup and uploads the fixed five-file + public candidate. +4. The coordinator verifies the candidate and contribution mathematics, writes + the next immutable chain artifacts, and signs an acceptance checkpoint. +5. The delivery service conditionally advances the current-head pointer only if + it still names the allocation checkpoint. + +V4 has no custody handoff, custody receipt, participant transport envelope or +coordinator submission acknowledgement. An attempt ID identifies delivery and +retry state; it does not change the existing signed contribution statement. +Byte-identical retries are safe, conflicting outputs are retained for +investigation, and accepted/rejected/retired attempts remain in bounded history. + +## Verification boundaries + +- `checkpoint inspect-signed-v4` authenticates one checkpoint pair only for + bounded dependency discovery. It does not validate ancestry or progress. +- `checkpoint verify-stored-v4` verifies complete signed ancestry and legal state + transitions. It does not claim that the delivery-service head is globally + current or replay contribution mathematics. +- `checkpoint allocate-v4` derives and signs the exact next allocation. +- `phase1 contribute` and `phase2 contribute`, when given a V4 allocation, verify + the allocation and immutable input snapshot before generating randomness. +- `checkpoint accept-candidate-v4` verifies the active allocation, candidate and + mathematics, then prepares and signs the descendant checkpoint. +- final-candidate preparation records the mandatory coordinator full replay and + binds its exact executable and closed file inventory. +- release review verifies that replay binding, the exact final files, enabled + assurance evidence and the required release signer. Independent signer or + auditor replay is optional additional assurance in V4. +- each V4 phase retains the exact coordinator-signed beacon record and its one + cryptographically verified drand response. A second endpoint may be tried as + an availability fallback, but V4 does not create a separate multi-relay + evidence record. Released V1-V3 verification rules remain unchanged. + +Large contribution payloads are hashed and verified as streams. Canonical JSON +records and signatures retain strict small-file limits. Final reports have their +own explicit bounds and are not authority merely because they were generated. + +## Compatibility gates + +- Parsers dispatch by authenticated definition/schema, never by missing fields or + a generic "latest" constant. +- Unknown versions fail closed. +- V1–V3 verification remains covered by compatibility tests. +- V4 uses final transcript V3 and production decision V3; the application key + manifest stays V1 because its signed transcript hash binds the new transcript. +- Provider credentials, bucket names, object keys and upload manifests remain + outside proof-tool's signed protocol types. +- Normal initialization must not emit V4 until the complete delivery-tool journey, + live storage tests and released binary pairing have passed. + +The V4 implementation has real Linux tiny-ceremony coverage for both phases, +coordinator replay, optional-assurance combinations, final signing, release +verification and negative cases. That evidence is not a production GO decision, +does not prove independent operators or physical erasure, and does not replace a +released end-to-end storage-backed rehearsal. diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md index ed71b4dd..f8ce9573 100644 --- a/docs/mpc-ceremony-release.md +++ b/docs/mpc-ceremony-release.md @@ -136,7 +136,7 @@ The next checkpoint fully replays that sealed Phase 1 state and accepts only the deterministic zero-contribution Phase 2 tree at `phase2/chain-0000.json`, `phase2/chain-0000.sig`, and `phase2/genesis.bin`. Merely uploading files with those names is insufficient. -Phase 2 participant checkpoints then use the same ordered +For released V1–V3 definitions, Phase 2 participant checkpoints use the same ordered outbound-handoff, signed-receipt, and accepted-candidate transitions as Phase 1. Each accepted candidate is fully replayed against the sealed Phase 1 commons. After the configured minimum is met, the graph accepts the canonical signed @@ -148,8 +148,10 @@ closed `final/candidate` tree: the coordinator-signed candidate, exact checksum inventory, final keys, Phase 2 seal, and public proof-verification evidence. Extra, missing, symbolic-link, nonregular or changed files are rejected. -For current definitions, release signing also independently replays both +For released V3 definitions, release signing also independently replays both phases on the release signer's machine even when ceremony audits are disabled. +Definition V4 instead requires the release signer to verify the coordinator's +exact full-replay binding and final files; an additional signer replay is optional. The following checkpoint accepts only the strictly verified closed `final/release` tree, including its release-signer manifest signature, operational evidence, explicit audit inventory, transcript, keys and checksums. diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index efb4461e..d1f7c0c0 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -90,11 +90,12 @@ is used. ## Beacon relay evidence -Final release requires matching, cryptographically verified responses from at -least **two distinct relay operators** for each phase's committed drand round. -Relay IDs and endpoint digests must also differ; multiple hostnames belonging -to one operator do not count as different operators. Every supplied response -must verify against the pinned network and exact committed round. +Released V1-V3 ceremonies require matching, cryptographically verified +responses from at least **two distinct relay operators** for each phase's +committed drand round. Relay IDs and endpoint digests must also differ; +multiple hostnames belonging to one operator do not count as different +operators. Every supplied response must verify against the pinned network and +exact committed round. This reduces the operational minimum from three operators to two, trading one source of retrieval redundancy for availability during a relay outage. It does @@ -107,6 +108,38 @@ three; use an explicitly reviewed compatible release. Do not edit an existing signed ceremony's software allowlist or replace its pinned binary to force an in-progress ceremony through a changed verifier policy. +Definition V4 instead retains the coordinator-signed beacon record and the +single raw drand response authenticated by that record. The delivery tool may +try another endpoint when the first is unavailable, but it accepts the first +cryptographically valid response for the committed round and does not create a +separate multi-relay evidence claim. This changes only explicitly signed V4 +ceremonies. + +## Experimental V4 retained-candidate inspection + +`inspect computation-output-v4` checks the three generated public files before +cleanup signing: `attestation.json`, `attestation.sig` and `contribution.bin`. +It checks the expected signed predecessor, participant, software and file bytes, +but not cleanup or process exit. It returns no candidate inventory ID. This lets +the controller recognize completed computation without rerunning it, then record +and execute cleanup signing as a separate operation. The controller must verify +the original container is absent before making that recovery decision. + +`inspect contribution-inventory-v4` reconstructs a retained contribution from +its exact signed predecessor and expected turn. It returns the fixed five-file +inventory after computation and cleanup attestation. V4 has no signed return +packet or participant transport envelope. Partial or inconsistent candidates +are errors and must not trigger another computation. + +This read-only inspection checks signatures, file hashes and locally checkable +chronology—not contribution mathematics, backend freshness, physical erasure or +acceptance. `checkpoint allocate-v4` and the V4 contribution flags bind the turn +to the exact authenticated checkpoint before randomness is generated. +Uploaders must recheck the returned file hashes. Extra local files +are not part of the upload inventory. Existing ceremony formats retain their +existing commands and verification requirements; V4 remains explicit opt-in +while the downstream workflow is being completed. + ## Toxic Waste Handling gnark samples the Groth16 trapdoor in process memory during `groth16.Setup`. diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 92758829..224c6667 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -234,6 +234,12 @@ func VerifyFinalCandidateCheckpoint(paths ReplayPaths, circuit *CompiledCircuit, if err := verifyCandidateReplay(circuit, &replay, paths, candidate, candidateDir); err != nil { return CandidateMetadata{}, nil, err } + return verifyCandidateClosedTree(replay.definition, replay.definitionRef, candidateDir, candidate, candidateRef) +} + +// Shared exact-file check. The caller decides whether its versioned trust +// model requires contribution replay; this helper never performs that replay. +func verifyCandidateClosedTree(definition CeremonyDefinition, definitionRef ArtifactRef, candidateDir string, candidate CandidateMetadata, candidateRef ArtifactRef) (CandidateMetadata, []ArtifactRef, error) { names := append(candidateChecksumNames(), CandidateChecksumsFile) expected := make(map[string]struct{}, len(names)) for _, name := range names { @@ -272,7 +278,7 @@ func VerifyFinalCandidateCheckpoint(paths ReplayPaths, circuit *CompiledCircuit, return CandidateMetadata{}, nil, errors.New("finalized candidate record changed during verification") } } - verifiedAgain, candidateRefAgain, err := verifyCandidate(replay.definition, replay.definitionRef, candidateDir) + verifiedAgain, candidateRefAgain, err := verifyCandidate(definition, definitionRef, candidateDir) if err != nil { return CandidateMetadata{}, nil, fmt.Errorf("finalized candidate changed during closed-tree verification: %w", err) } @@ -369,6 +375,25 @@ func compareCandidateToReplay( if _, err := readCanonicalFile(filepath.Join(dir, candidate.VerificationReport.Name), &candidateReport); err != nil { return err } + if err := validateCandidatePublicReport(candidateReport, cardanoVK, format); err != nil { + return err + } + if err := verifyPublicFinalizationEvidence(dir, candidate, candidateReport); err != nil { + return err + } + if _, _, _, err := loadAndVerifyPublicEvidence( + filepath.Join(dir, candidate.PublicEvidence.Name), + replay.definition.CeremonyID, + vk, + cardanoVK, + candidate.CardanoVerifyingKey, + ); err != nil { + return fmt.Errorf("independent native public-evidence verification: %w", err) + } + return nil +} + +func validateCandidatePublicReport(candidateReport VerificationReport, cardanoVK []byte, format string) error { if candidateReport.CardanoVKRawDigest != NewDigest(cardanoVK) || candidateReport.CardanoVKBytes != len(cardanoVK) || candidateReport.CardanoVKFormat != format || @@ -384,21 +409,28 @@ func compareCandidateToReplay( !candidateReport.ProofAppendRejected { return errors.New("candidate verification report is not reproduced by independent evidence") } - if err := verifyPublicFinalizationEvidence(dir, candidate, candidateReport); err != nil { - return err - } - if _, _, _, err := loadAndVerifyPublicEvidence( - filepath.Join(dir, candidate.PublicEvidence.Name), - replay.definition.CeremonyID, - vk, - cardanoVK, - candidate.CardanoVerifyingKey, - ); err != nil { - return fmt.Errorf("independent native public-evidence verification: %w", err) - } return nil } +// Pin released replay semantics to explicit identifiers, not the moving +// DefinitionSchema default. Future formats must add a separately verified path. +func verifyRequiredReleaseSignerReplay(schema string, options SignReleaseOptions) error { + switch schema { + case DefinitionSchemaV1, DefinitionSchemaV2: + return nil + case DefinitionSchemaV3: + if options.Replay == nil || options.Circuit == nil { + return errors.New("storage-first release signing requires independent two-phase replay inputs") + } + if _, err := ReplayCandidate(*options.Replay, options.Circuit, options.CandidateDir); err != nil { + return fmt.Errorf("release-signer independent replay: %w", err) + } + return nil + default: + return fmt.Errorf("unsupported release-signing definition schema %q", schema) + } +} + // SignRelease validates the signed definition's required passing-audit count, // assembles the final setup transcript and key manifest without // replacing candidate files, then signs the exact manifest with the distinct @@ -431,13 +463,8 @@ func SignRelease(options SignReleaseOptions) (*SignReleaseResult, error) { if err != nil { return nil, err } - if definition.Schema == DefinitionSchema { - if options.Replay == nil || options.Circuit == nil { - return nil, errors.New("storage-first release signing requires independent two-phase replay inputs") - } - if _, err := ReplayCandidate(*options.Replay, options.Circuit, options.CandidateDir); err != nil { - return nil, fmt.Errorf("release-signer independent replay: %w", err) - } + if err := verifyRequiredReleaseSignerReplay(definition.Schema, options); err != nil { + return nil, err } if options.SignatureKeyID != definition.ReleaseSigner.KeyID { return nil, fmt.Errorf( @@ -661,6 +688,9 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { if err := requireIdentityKey(definition.Coordinator, coordinatorPublicKey); err != nil { return nil, err } + if definition.Schema == DefinitionSchemaV4 { + return nil, errors.New("definition v4 requires the versioned trusted-coordinator release verification path") + } if options.ExpectedSignatureKeyID != definition.ReleaseSigner.KeyID { return nil, errors.New("expected release signature key id does not match ceremony definition") } @@ -703,6 +733,9 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { if err != nil { return nil, err } + if transcript.Schema == FinalTranscriptSchemaV3 { + return nil, errors.New("final transcript v3 requires the definition v4 release path") + } bundledAudits, err := bundledAuditsForTranscript(options.KeysDir, transcript.Audits) if err != nil { return nil, err @@ -766,14 +799,7 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { len(manifest.ArtifactURLs) != 0 { return nil, errors.New("manifest does not exactly bind candidate key artifacts and signed provenance") } - if _, err := ReadR1CSFile(filepath.Join(options.KeysDir, candidate.ConstraintSystem.Name), definition.Circuit); err != nil { - return nil, err - } - vk, err := prover.LoadVK(filepath.Join(options.KeysDir, NativeVerifyingKeyFile)) - if err != nil { - return nil, err - } - if err := verifyCardanoFiles(options.KeysDir, candidate, vk); err != nil { + if _, err := verifyCandidateKeyExports(definition, candidate, options.KeysDir); err != nil { return nil, err } if err := verifyChecksumsExact( @@ -1073,16 +1099,57 @@ func verifyPassingAudits( candidate CandidateMetadata, inputs []AuditArtifact, ) ([]ArtifactRef, time.Time, error) { + if err := validateAuditCollectionCount(definition, len(inputs), true); err != nil { + return nil, time.Time{}, err + } + raw := make([]signedAuditInput, 0, len(inputs)) + for index, input := range inputs { + record, err := readRegularFile(input.RecordPath) + if err != nil { + return nil, time.Time{}, fmt.Errorf("audit %d: %w", index, err) + } + signature, err := readRegularFile(input.SignaturePath) + if err != nil { + return nil, time.Time{}, fmt.Errorf("audit %d signature: %w", index, err) + } + name := input.LogicalName + if name == "" { + name = filepath.Base(input.RecordPath) + } + raw = append(raw, signedAuditInput{record: record, signature: signature, name: name}) + } + return verifyAuditCollection(definition, candidate, raw, true) +} + +type signedAuditInput struct { + record, signature []byte + name string +} + +// Collection mode postpones only the count gate. It does not weaken the +// signed disabled-policy rule, signatures, candidate binding or uniqueness. +func verifyAuditCollection(definition CeremonyDefinition, candidate CandidateMetadata, inputs []signedAuditInput, requireMinimum bool) ([]ArtifactRef, time.Time, error) { + if err := validateAuditCollectionCount(definition, len(inputs), requireMinimum); err != nil { + return nil, time.Time{}, err + } + return verifyAuditCollectionRecords(definition, candidate, inputs) +} + +func validateAuditCollectionCount(definition CeremonyDefinition, count int, requireMinimum bool) error { minimum := 1 - if definition.Schema == DefinitionSchema { + if definition.UsesSignedAssurancePolicy() { minimum = int(definition.AssurancePolicy.PassingCeremonyAudits) } - if len(inputs) < minimum { - return nil, time.Time{}, fmt.Errorf("have %d passing ceremony audits, need %d", len(inputs), minimum) + if requireMinimum && count < minimum { + return fmt.Errorf("have %d passing ceremony audits, need %d", count, minimum) } - if minimum == 0 && len(inputs) != 0 { - return nil, time.Time{}, errors.New("ceremony audit artifacts are forbidden when audits are disabled") + if minimum == 0 && count != 0 { + return errors.New("ceremony audit artifacts are forbidden when audits are disabled") } + return nil +} + +func verifyAuditCollectionRecords(definition CeremonyDefinition, candidate CandidateMetadata, inputs []signedAuditInput) ([]ArtifactRef, time.Time, error) { replayRoot, err := replayRootSHA256(candidate) if err != nil { return nil, time.Time{}, err @@ -1104,14 +1171,7 @@ func verifyPassingAudits( Digest: NewDigest(candidateBytes), }) for index, input := range inputs { - recordBytes, err := readRegularFile(input.RecordPath) - if err != nil { - return nil, time.Time{}, fmt.Errorf("audit %d: %w", index, err) - } - signatureBytes, err := readRegularFile(input.SignaturePath) - if err != nil { - return nil, time.Time{}, fmt.Errorf("audit %d signature: %w", index, err) - } + recordBytes, signatureBytes := input.record, input.signature var unsigned AuditRecord if err := UnmarshalCanonical(recordBytes, &unsigned); err != nil { return nil, time.Time{}, fmt.Errorf("audit %d: %w", index, err) @@ -1158,11 +1218,7 @@ func verifyPassingAudits( } seenAuditor[record.AuditorID] = struct{}{} seenKey[record.AuditorKeyID] = struct{}{} - name := input.LogicalName - if name == "" { - name = filepath.Base(input.RecordPath) - } - ref := ArtifactRef{Name: name, Digest: NewDigest(recordBytes)} + ref := ArtifactRef{Name: input.name, Digest: NewDigest(recordBytes)} if err := ref.Validate(); err != nil { return nil, time.Time{}, err } @@ -1238,7 +1294,11 @@ func streamingDigest(write func(io.Writer) (int64, error)) (Digest, error) { } func verifyChecksumsExact(dir, checksumPath string, expectedNames []string) error { - data, err := readRegularFile(checksumPath) + return verifyChecksumsExactWithLimit(dir, checksumPath, expectedNames, maxSignedRecordBytes) +} + +func verifyChecksumsExactWithLimit(dir, checksumPath string, expectedNames []string, limit int64) error { + data, err := readRegularBounded(checksumPath, limit) if err != nil { return err } @@ -1246,47 +1306,61 @@ func verifyChecksumsExact(dir, checksumPath string, expectedNames []string) erro if err != nil { return fmt.Errorf("checksum file path: %w", err) } + entries, err := parseChecksumsExact(data, checksumName, expectedNames) + if err != nil { + return err + } + for _, entry := range entries { + path, err := resolveArtifactPath(dir, entry.name) + if err != nil { + return err + } + ref, err := artifactRefForFile(entry.name, path) + if err != nil { + return err + } + if strings.TrimPrefix(ref.Digest.SHA256, "sha256:") != entry.sha256 { + return fmt.Errorf("checksum mismatch for %q", entry.name) + } + } + return nil +} + +type checksumEntry struct{ name, sha256 string } + +func parseChecksumsExact(data []byte, checksumName string, expectedNames []string) ([]checksumEntry, error) { lines := strings.Split(strings.TrimSuffix(string(data), "\n"), "\n") if len(lines) == 0 || (len(lines) == 1 && lines[0] == "") { - return errors.New("checksum file is empty") + return nil, errors.New("checksum file is empty") } expected := append([]string(nil), expectedNames...) slices.Sort(expected) if len(lines) != len(expected) { - return fmt.Errorf("checksum file has %d entries, want exactly %d", len(lines), len(expected)) + return nil, fmt.Errorf("checksum file has %d entries, want exactly %d", len(lines), len(expected)) } seen := make(map[string]struct{}, len(lines)) + entries := make([]checksumEntry, 0, len(lines)) for index, line := range lines { if len(line) < 67 || line[64:66] != " " { - return errors.New("invalid checksum line") + return nil, errors.New("invalid checksum line") } hashHex, name := line[:64], line[66:] if _, err := hex.DecodeString(hashHex); err != nil { - return errors.New("invalid checksum hash") + return nil, errors.New("invalid checksum hash") } if err := validateArtifactName(name); err != nil || name == checksumName { - return errors.New("invalid checksum artifact name") + return nil, errors.New("invalid checksum artifact name") } if name != expected[index] { - return fmt.Errorf("checksum entry %d is %q, want %q", index, name, expected[index]) + return nil, fmt.Errorf("checksum entry %d is %q, want %q", index, name, expected[index]) } if _, duplicate := seen[name]; duplicate { - return fmt.Errorf("duplicate checksum for %q", name) + return nil, fmt.Errorf("duplicate checksum for %q", name) } seen[name] = struct{}{} - path, err := resolveArtifactPath(dir, name) - if err != nil { - return err - } - ref, err := artifactRefForFile(name, path) - if err != nil { - return err - } - if strings.TrimPrefix(ref.Digest.SHA256, "sha256:") != hashHex { - return fmt.Errorf("checksum mismatch for %q", name) - } + entries = append(entries, checksumEntry{name, hashHex}) } - return nil + return entries, nil } func candidateChecksumNames() []string { @@ -1746,12 +1820,13 @@ func publishReleaseDirectory(stagingDir, releaseDir string) (err error) { } func verifyReleaseTreeExact(dir string, auditCount int, operationalNames []string) error { + return verifyExactReleaseFiles(dir, append(releaseChecksumNames(auditCount, operationalNames), ReleaseChecksumsFile), false) +} + +func verifyExactReleaseFiles(dir string, names []string, rejectHardlinks bool) error { expectedFiles := make(map[string]struct{}) expectedDirectories := map[string]struct{}{".": {}} - for _, name := range append( - releaseChecksumNames(auditCount, operationalNames), - ReleaseChecksumsFile, - ) { + for _, name := range names { if err := validateArtifactName(name); err != nil { return fmt.Errorf("expected release artifact %q: %w", name, err) } @@ -1789,6 +1864,11 @@ func verifyReleaseTreeExact(dir string, auditCount int, operationalNames []strin if !info.Mode().IsRegular() { return fmt.Errorf("release-tree entry %q is not a regular file", name) } + if rejectHardlinks { + if err := requireSingleLinkV4(info); err != nil { + return fmt.Errorf("release-tree entry %q: %w", name, err) + } + } if _, ok := expectedFiles[name]; !ok { return fmt.Errorf("unexpected release-tree entry %q", name) } diff --git a/internal/mpcceremony/candidate_invalid_v4.go b/internal/mpcceremony/candidate_invalid_v4.go new file mode 100644 index 00000000..5fef09e1 --- /dev/null +++ b/internal/mpcceremony/candidate_invalid_v4.go @@ -0,0 +1,85 @@ +package mpcceremony + +import "errors" + +// CandidateInvalidError identifies a semantic failure in a complete candidate +// after the caller has authenticated the ceremony, exact turn, and candidate +// directory. It deliberately does not cover missing files, filesystem errors, +// symlink/TOCTOU defenses, or failed trust and predecessor checks: those leave +// the candidate's state uncertain and must be investigated rather than +// rejected as content-invalid. +type CandidateInvalidError struct { + err error +} + +func (e *CandidateInvalidError) Error() string { return e.err.Error() } + +func (e *CandidateInvalidError) Unwrap() error { return e.err } + +// CandidateInvalid is a marker consumed by the machine-readable command +// boundary. It has no protocol meaning outside candidate inspection. +func (*CandidateInvalidError) CandidateInvalid() {} + +// candidateInvalid marks a semantic candidate-validation failure. It is kept +// internal to the ceremony package so callers cannot relabel operational +// failures as invalid candidates. +func candidateInvalid(err error) error { + if err == nil { + return nil + } + return &CandidateInvalidError{err: err} +} + +// IsCandidateInvalid reports whether inspection completed the trust, scope, +// predecessor, and directory-opening stages and then found invalid candidate +// semantics. +func IsCandidateInvalid(err error) bool { + var invalid *CandidateInvalidError + return errors.As(err, &invalid) +} + +// candidateArtifactContentError marks bytes that were opened successfully but +// cannot be decoded as the required canonical ceremony artifact. Filesystem, +// path, and short-read failures deliberately remain unmarked. +type candidateArtifactContentError struct { + err error +} + +func (e *candidateArtifactContentError) Error() string { return e.err.Error() } + +func (e *candidateArtifactContentError) Unwrap() error { return e.err } + +func candidateArtifactContent(err error) error { + if err == nil { + return nil + } + return &candidateArtifactContentError{err: err} +} + +func isCandidateArtifactContent(err error) bool { + var invalid *candidateArtifactContentError + return errors.As(err, &invalid) +} + +// candidateArtifactDigestMismatchError is narrower than an arbitrary read +// failure: the file was opened and read without changing, but its bytes do not +// match the participant's signed artifact reference. +type candidateArtifactDigestMismatchError struct { + err error +} + +func (e *candidateArtifactDigestMismatchError) Error() string { return e.err.Error() } + +func (e *candidateArtifactDigestMismatchError) Unwrap() error { return e.err } + +func candidateArtifactDigestMismatch(err error) error { + if err == nil { + return nil + } + return &candidateArtifactDigestMismatchError{err: err} +} + +func isCandidateArtifactDigestMismatch(err error) bool { + var invalid *candidateArtifactDigestMismatchError + return errors.As(err, &invalid) +} diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index 06d77c87..6df106e2 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -351,28 +351,13 @@ func ValidateAttestationAcceptance( if !ok || participant.Identity.KeyID != attestation.ParticipantKeyID { return errors.New("attestation participant identity does not match definition") } - if !definition.Software.AllowsToolBinary(attestation.ToolBinary) || - definition.Software.SourceCommit != attestation.SourceCommit || - definition.Software.GnarkVersion != attestation.GnarkVersion || - definition.Software.GnarkCryptoVersion != attestation.GnarkCryptoVersion || - definition.Software.DrandVersion != attestation.DrandVersion { - return errors.New("attestation software binding does not match definition") + if err := validateAttestationSoftwareBinding(definition, attestation); err != nil { + return err } - createdAt, _ := time.Parse(time.RFC3339Nano, definition.CreatedAt) - contributedAt, _ := time.Parse(time.RFC3339Nano, attestation.ContributedAt) destroyedAt, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt) acceptedAt, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt) - if !contributedAt.After(createdAt) { - return errors.New("contributed_at must be strictly after the ceremony definition") - } - if len(chain.Records) > 0 { - previousAcceptedAt, _ := time.Parse( - time.RFC3339Nano, - chain.Records[len(chain.Records)-1].AcceptedAt, - ) - if !contributedAt.After(previousAcceptedAt) { - return errors.New("contributed_at must be strictly after the previous acceptance") - } + if err := validateContributionChronology(definition, chain, attestation); err != nil { + return err } if !acceptedAt.After(destroyedAt) { return errors.New("accepted_at must be strictly after destroyed_at") @@ -608,7 +593,7 @@ func ValidateClose(definition CeremonyDefinition, chain Chain, close CloseRecord // witness receipt unsatisfiable. See ProductionWitnessObservationWindowSeconds. func requiredCloseLead(definition CeremonyDefinition) time.Duration { lead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second - witnessesEnabled := definition.Schema != DefinitionSchema || + witnessesEnabled := !definition.UsesSignedAssurancePolicy() || (definition.AssurancePolicy != nil && definition.AssurancePolicy.PublicWitnessesPerPhase > 0) if definition.Mode == ModeProduction && witnessesEnabled { lead += time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second @@ -1159,6 +1144,7 @@ type FinalTranscript struct { VerifyingKey ArtifactRef `json:"verifying_key"` CardanoVerifyingKey ArtifactRef `json:"cardano_verifying_key"` FinalizedAt string `json:"finalized_at"` + ReleaseReview *ReleaseReviewV4 `json:"release_review,omitempty"` } func NewFinalTranscript(record FinalTranscript) (FinalTranscript, error) { @@ -1169,7 +1155,7 @@ func NewFinalTranscript(record FinalTranscript) (FinalTranscript, error) { record.Schema = FinalTranscriptSchema } } - if record.Schema == FinalTranscriptSchema && record.Audits == nil { + if (record.Schema == FinalTranscriptSchema || record.Schema == FinalTranscriptSchemaV3) && record.Audits == nil { record.Audits = []ArtifactRef{} } record.TranscriptID = "" @@ -1186,9 +1172,14 @@ func ComputeFinalTranscriptID(record FinalTranscript) (string, error) { if err := record.validate(false); err != nil { return "", err } - domain := "proof-tool/mpc-ceremony/final-transcript/v2" - if record.Schema == FinalTranscriptSchemaV1 { + var domain string + switch record.Schema { + case FinalTranscriptSchemaV1: domain = "proof-tool/mpc-ceremony/final-transcript/v1" + case FinalTranscriptSchemaV3: + domain = "proof-tool/mpc-ceremony/final-transcript/v3" + default: + domain = "proof-tool/mpc-ceremony/final-transcript/v2" } return canonicalHash(domain, record) } @@ -1208,8 +1199,11 @@ func (r FinalTranscript) Validate() error { } func (r FinalTranscript) validate(requireID bool) error { + if r.Schema != FinalTranscriptSchemaV3 && r.ReleaseReview != nil { + return errors.New("legacy final transcripts must not contain release_review") + } switch r.Schema { - case FinalTranscriptSchema: + case FinalTranscriptSchema, FinalTranscriptSchemaV3: if r.AssurancePolicy == nil { return errors.New("final transcript v2 requires assurance_policy") } @@ -1223,6 +1217,11 @@ func (r FinalTranscript) validate(requireID bool) error { default: return fmt.Errorf("transcript schema %q is unsupported", r.Schema) } + if r.Schema == FinalTranscriptSchemaV3 { + if err := validateFinalTranscriptReviewV3(r); err != nil { + return err + } + } if requireID { if err := validateHashID("transcript_id", r.TranscriptID); err != nil { return err @@ -1254,7 +1253,7 @@ func (r FinalTranscript) validate(requireID bool) error { if r.Schema == FinalTranscriptSchemaV1 && len(r.Audits) < 1 { return errors.New("final transcript requires at least one independent audit artifact") } - if r.Schema == FinalTranscriptSchema { + if r.Schema == FinalTranscriptSchema || r.Schema == FinalTranscriptSchemaV3 { if len(r.Audits) < int(r.AssurancePolicy.PassingCeremonyAudits) { return fmt.Errorf("final transcript has %d audits, below signed minimum %d", len(r.Audits), r.AssurancePolicy.PassingCeremonyAudits) } diff --git a/internal/mpcceremony/checkpoint.go b/internal/mpcceremony/checkpoint.go index e4613dc2..c64c8277 100644 --- a/internal/mpcceremony/checkpoint.go +++ b/internal/mpcceremony/checkpoint.go @@ -687,7 +687,10 @@ func VerifySignedCheckpoint(definition CeremonyDefinition, definitionBytes, defi } func validateCheckpointDefinitionVersion(definition CeremonyDefinition, checkpoint Checkpoint) error { - if definition.Schema == DefinitionSchema { + if definition.Schema == DefinitionSchemaV4 { + return errors.New("definition v4 requires the versioned trusted-coordinator checkpoint path") + } + if definition.Schema == DefinitionSchemaV3 { if (checkpoint.Schema != CheckpointSchema && checkpoint.Schema != CheckpointSchemaV2) || checkpoint.AssurancePolicy == nil || *checkpoint.AssurancePolicy != *definition.AssurancePolicy { return errors.New("definition v3 requires a checkpoint v2 or v3 with exactly matching assurance_policy") } diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go new file mode 100644 index 00000000..74d61c1f --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4.go @@ -0,0 +1,751 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "reflect" + "slices" +) + +const ( + CheckpointSchemaV4 = "proof-tool-mpc-checkpoint-v4" + StorageFirstWorkflowV2 = "storage-first-v2" + MaxCheckpointSequenceV4 = 16384 + CheckpointPhase1CandidateAllocated CheckpointTransitionKind = "phase1-candidate-allocated" + CheckpointPhase2CandidateAllocated CheckpointTransitionKind = "phase2-candidate-allocated" + CheckpointDeliveryRetired CheckpointTransitionKind = "delivery-retired" + CheckpointContributionRejected CheckpointTransitionKind = "contribution-rejected" + CheckpointDeliveryReallocated CheckpointTransitionKind = "delivery-reallocated" + CheckpointEnrollmentRecorded CheckpointTransitionKind = "enrollment-recorded" + CheckpointMirrorRecorded CheckpointTransitionKind = "mirror-recorded" + CheckpointWitnessRecorded CheckpointTransitionKind = "witness-recorded" + CheckpointAuditRecorded CheckpointTransitionKind = "audit-recorded" + CheckpointReleaseReviewRecorded CheckpointTransitionKind = "release-review-recorded" + CheckpointIncidentRecorded CheckpointTransitionKind = "incident-recorded" + CheckpointAborted CheckpointTransitionKind = "ceremony-aborted" + CheckpointRestarted CheckpointTransitionKind = "ceremony-restarted" +) + +// CheckpointProgressV4 is the protocol projection used for guidance. It is not +// proof that referenced files have been downloaded or mathematically replayed. +type CheckpointProgressV4 struct { + Phase1 CheckpointPhaseState `json:"phase1"` + Phase1Closure *SignedArtifactRefs `json:"phase1_closure,omitempty"` + Phase1Beacon *SignedArtifactRefs `json:"phase1_beacon,omitempty"` + Phase1Seal *SignedArtifactRefs `json:"phase1_seal,omitempty"` + Phase2 *CheckpointPhaseState `json:"phase2,omitempty"` + Phase2Closure *SignedArtifactRefs `json:"phase2_closure,omitempty"` + Phase2Beacon *SignedArtifactRefs `json:"phase2_beacon,omitempty"` + FinalCandidate *SignedArtifactRefs `json:"final_candidate,omitempty"` + ReleaseReview *SignedArtifactRefs `json:"release_review,omitempty"` + FinalRelease *SignedArtifactRefs `json:"final_release,omitempty"` + Terminal *CheckpointTerminalV4 `json:"terminal,omitempty"` +} + +// Restart is an old-side authorization, not a lineage claim by the new +// definition. A caller relying on that lineage must retain this checkpoint. +type CheckpointTerminalV4 struct { + Kind GovernanceKind `json:"kind"` + Record SignedArtifactRefs `json:"record"` + RestartDefinition *SignedArtifactRefs `json:"restart_definition,omitempty"` +} + +type CheckpointTransitionV4 struct { + Kind CheckpointTransitionKind `json:"kind"` + Scope *ContributionScope `json:"scope,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + NextAttemptID string `json:"next_attempt_id,omitempty"` + AllocatedAt string `json:"allocated_at,omitempty"` + Record *SignedArtifactRefs `json:"record,omitempty"` + Evidence []ArtifactRef `json:"evidence"` + Contribution *CandidateInventory `json:"contribution,omitempty"` + ReplayVerification *CheckpointReplayVerificationV4 `json:"replay_verification,omitempty"` + RestartDefinition *SignedArtifactRefs `json:"restart_definition,omitempty"` +} + +// This is the coordinator's authenticated replay claim, not a proof that an +// untrusted coordinator actually ran the computation. +type CheckpointReplayVerificationV4 struct { + Method string `json:"method"` + ToolBinary Digest `json:"tool_binary"` +} + +// CheckpointV4 deliberately has no backend object key, release-image ID, +// participant delivery envelope, or separate coordinator acknowledgement. +// The coordinator signature commits the disposition and exact protocol files. +// Definition V1–V3 never use this type or its less demanding signer-replay rule. +type CheckpointV4 struct { + Schema string `json:"schema"` + Workflow string `json:"workflow"` + CeremonyID string `json:"ceremony_id"` + Definition SignedArtifactRefs `json:"definition"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy"` + ReleaseVerification string `json:"release_verification"` + Sequence uint64 `json:"sequence"` + PreviousCheckpoint *SignedArtifactRefs `json:"previous_checkpoint,omitempty"` + Transition CheckpointTransitionV4 `json:"transition"` + Progress CheckpointProgressV4 `json:"progress"` + AcceptedArtifacts []ArtifactRef `json:"accepted_artifacts"` + Deliveries []DeliverySlotV2 `json:"deliveries"` +} + +func validateV4ArtifactSet(refs []ArtifactRef, maximum int) error { + if refs == nil || len(refs) > maximum { + return errors.New("artifact list must be explicit and bounded") + } + names := map[string]bool{} + for i, ref := range refs { + if err := ref.Validate(); err != nil { + return err + } + if err := validatePortableStorageName(ref.Name); err != nil { + return err + } + if i > 0 && refs[i-1].Name >= ref.Name { + return errors.New("artifact names must be sorted, unique, and must not overlap as files and directories") + } + for offset, r := range ref.Name { + if r == '/' && names[ref.Name[:offset]] { + return errors.New("artifact names overlap as files and directories") + } + } + names[ref.Name] = true + } + return nil +} + +func (c CheckpointV4) Validate() error { + if c.Schema != CheckpointSchemaV4 || c.Workflow != StorageFirstWorkflowV2 || c.ReleaseVerification != CoordinatorReplayReleaseV1 { + return errors.New("checkpoint v4 requires the explicit trusted-coordinator workflow") + } + if err := validateHashID("ceremony_id", c.CeremonyID); err != nil { + return err + } + if err := c.Definition.Validate(); err != nil { + return err + } + if c.AssurancePolicy == nil { + return errors.New("checkpoint v4 requires explicit assurance policy") + } + if c.Sequence > MaxCheckpointSequenceV4 { + return errors.New("checkpoint sequence exceeds protocol limit") + } + if (c.Sequence == 0) != (c.PreviousCheckpoint == nil) || (c.Sequence == 0) != (c.Transition.Kind == CheckpointInitial) { + return errors.New("only the initial checkpoint has sequence zero and no predecessor") + } + if c.PreviousCheckpoint != nil { + if err := c.PreviousCheckpoint.Validate(); err != nil { + return err + } + for _, ref := range signedArtifacts(c.PreviousCheckpoint) { + if err := validatePortableStorageName(ref.Name); err != nil { + return err + } + } + } + artifactLimit := MaxCheckpointArtifacts + if c.Transition.Kind == CheckpointFinalReleaseRecorded { + artifactLimit += 5 + } + if err := validateV4ArtifactSet(c.AcceptedArtifacts, artifactLimit); err != nil { + return err + } + if err := ValidateDeliveryHistoryV2(c.Deliveries); err != nil { + return err + } + for _, slot := range c.Deliveries { + if slot.Kind != CheckpointSubmissionCandidate { + return errors.New("checkpoint v4 supports candidate delivery attempts only") + } + } + if err := c.Transition.Validate(); err != nil { + return err + } + if err := c.Progress.Phase1.Validate(); err != nil { + return err + } + if c.Progress.Phase1.Phase != Phase1 { + return errors.New("phase1 projection identifies another phase") + } + refs := []ArtifactRef{c.Definition.Record, c.Definition.Signature, c.Progress.Phase1.HeadPayload, c.Progress.Phase1.Chain.Record, c.Progress.Phase1.Chain.Signature} + if c.Progress.Phase2 != nil { + if c.Progress.Phase1Seal == nil || c.Progress.Phase2.Phase != Phase2 { + return errors.New("phase2 requires a sealed phase1") + } + if err := c.Progress.Phase2.Validate(); err != nil { + return err + } + refs = append(refs, c.Progress.Phase2.HeadPayload, c.Progress.Phase2.Chain.Record, c.Progress.Phase2.Chain.Signature) + } + stages := []*SignedArtifactRefs{c.Progress.Phase1Closure, c.Progress.Phase1Beacon, c.Progress.Phase1Seal, c.Progress.Phase2Closure, c.Progress.Phase2Beacon, c.Progress.FinalCandidate, c.Progress.ReleaseReview, c.Progress.FinalRelease} + missing := false + for i, stage := range stages { + if stage == nil { + missing = true + continue + } + if missing || (i >= 3 && c.Progress.Phase2 == nil) { + return errors.New("checkpoint skipped a required lifecycle stage") + } + if err := stage.Validate(); err != nil { + return err + } + refs = append(refs, stage.Record, stage.Signature) + } + refs = append(refs, signedArtifacts(c.Transition.Record)...) + refs = append(refs, c.Transition.Evidence...) + if terminal := c.Progress.Terminal; terminal != nil { + if c.Progress.FinalRelease != nil || (terminal.Kind != GovernanceAbort && terminal.Kind != GovernanceRestart) || terminal.Kind != governanceKindV4(c.Transition.Kind) || c.Transition.Record == nil || terminal.Record != *c.Transition.Record || !reflect.DeepEqual(terminal.RestartDefinition, c.Transition.RestartDefinition) { + return errors.New("terminal marker must match an abort/restart edge before release") + } + } else if c.Transition.Kind == CheckpointAborted || c.Transition.Kind == CheckpointRestarted { + return errors.New("abort/restart requires its terminal marker") + } + for _, ref := range refs { + if !slices.Contains(c.AcceptedArtifacts, ref) { + return errors.New("checkpoint references an artifact outside its accepted inventory") + } + } + for _, slot := range c.Deliveries { + if slot.Scope.CeremonyID != c.CeremonyID { + return errors.New("delivery belongs to another ceremony") + } + if slot.Status == DeliveryAllocated { + // Termination retains unresolved history; it does not authorize use + // of those slots or wait for external credential expiry. + projection := c.Progress + projection.Terminal = nil + if err := projection.currentTurn(slot.Scope); err != nil { + return err + } + } + } + if c.Sequence == 0 { + if c.Progress.Phase1.AcceptedCount != 0 || c.Progress.Phase2 != nil || c.Progress.Phase1Closure != nil || len(c.Deliveries) != 0 || len(c.AcceptedArtifacts) != 6 { + return errors.New("initial checkpoint must contain exactly the signed definition, circuit and genesis chain/payload") + } + } + return nil +} + +func (t CheckpointTransitionV4) Validate() error { + if t.Kind == CheckpointRestarted { + if t.RestartDefinition == nil { + return errors.New("restart requires the exact new signed definition") + } + if err := t.RestartDefinition.Validate(); err != nil { + return err + } + } else if t.RestartDefinition != nil { + return errors.New("only restart may name a new definition") + } + if t.Kind == CheckpointFinalCandidateRecorded { + if t.ReplayVerification == nil || t.ReplayVerification.Method != CoordinatorReplayReleaseV1 { + return errors.New("final candidate requires the explicit coordinator full replay claim") + } + if err := t.ReplayVerification.ToolBinary.Validate(); err != nil { + return err + } + } else if t.ReplayVerification != nil { + return errors.New("only final candidate preparation records full replay verification") + } + if err := validateV4ArtifactSet(t.Evidence, MaxCheckpointArtifacts); err != nil { + return err + } + if t.Kind == CheckpointInitial { + if t.Scope != nil || t.AttemptID != "" || t.NextAttemptID != "" || t.AllocatedAt != "" || t.Record != nil || t.Contribution != nil || len(t.Evidence) != 0 { + return errors.New("initial transition has extra fields") + } + return nil + } + turn := t.Kind == CheckpointPhase1CandidateAllocated || t.Kind == CheckpointPhase2CandidateAllocated || t.Kind == CheckpointPhase1CandidateAccepted || t.Kind == CheckpointPhase2CandidateAccepted || t.Kind == CheckpointDeliveryRetired || t.Kind == CheckpointContributionRejected || t.Kind == CheckpointDeliveryReallocated + if turn { + if t.Scope == nil { + return errors.New("turn transition requires contribution scope") + } + if err := t.Scope.Validate(); err != nil { + return err + } + if err := validateHex(t.AttemptID, 16); err != nil { + return err + } + switch t.Kind { + case CheckpointPhase2CandidateAllocated, CheckpointPhase2CandidateAccepted: + if t.Scope.Phase != Phase2 { + return errors.New("transition kind and phase disagree") + } + case CheckpointDeliveryRetired, CheckpointContributionRejected, CheckpointDeliveryReallocated: + default: + if t.Scope.Phase != Phase1 { + return errors.New("transition kind and phase disagree") + } + } + replacement := t.Kind == CheckpointDeliveryReallocated || ((t.Kind == CheckpointDeliveryRetired || t.Kind == CheckpointContributionRejected) && t.NextAttemptID != "") + if replacement { + if err := validateHex(t.NextAttemptID, 16); err != nil { + return err + } + if t.NextAttemptID == t.AttemptID { + return errors.New("replacement delivery requires a fresh attempt ID") + } + } else if t.NextAttemptID != "" { + return errors.New("unexpected next attempt") + } + candidate := t.Kind == CheckpointPhase1CandidateAccepted || t.Kind == CheckpointPhase2CandidateAccepted || t.Kind == CheckpointContributionRejected + if candidate != (t.Contribution != nil) { + return errors.New("candidate disposition requires its complete inventory and other transitions forbid it") + } + if candidate { + if err := t.Contribution.Validate(); err != nil { + return err + } + if t.Contribution.Scope != *t.Scope { + return errors.New("transition inventory scope differs") + } + } + allocated := t.Kind == CheckpointPhase1CandidateAllocated || t.Kind == CheckpointPhase2CandidateAllocated + if allocated { + if err := validateTimestamp("allocated_at", t.AllocatedAt); err != nil { + return err + } + } else if t.AllocatedAt != "" { + return errors.New("only candidate allocation records allocated_at") + } + switch { + case allocated: + if t.Record != nil || len(t.Evidence) != 0 { + return errors.New("candidate allocation is authorized by the checkpoint itself and adds no evidence") + } + return nil + case t.Kind == CheckpointDeliveryRetired || t.Kind == CheckpointContributionRejected || t.Kind == CheckpointDeliveryReallocated: + if t.Record != nil || len(t.Evidence) != 0 { + return errors.New("delivery-only changes must not publish payloads as accepted evidence") + } + return nil + } + } else { + if t.Scope != nil || t.AttemptID != "" || t.NextAttemptID != "" || t.AllocatedAt != "" || t.Contribution != nil { + return errors.New("lifecycle transition must not contain turn fields") + } + switch t.Kind { + case CheckpointIncidentRecorded, CheckpointAborted: + if len(t.Evidence) != 1 { + return errors.New("incident/abort requires exactly one public statement") + } + case CheckpointRestarted: + if len(t.Evidence) != 3 || !slices.Contains(t.Evidence, t.RestartDefinition.Record) || !slices.Contains(t.Evidence, t.RestartDefinition.Signature) { + return errors.New("restart requires only its public statement and exact new definition pair") + } + case CheckpointEnrollmentRecorded: + if len(t.Evidence) != 1 { + return errors.New("enrollment transition requires its disclosure artifact") + } + case CheckpointMirrorRecorded, CheckpointWitnessRecorded, CheckpointAuditRecorded: + if len(t.Evidence) != 0 { + return errors.New("assurance evidence edge adds only its signed record") + } + case CheckpointPhase1Closed, CheckpointPhase2Closed: + if len(t.Evidence) != 0 { + return errors.New("closure transition only adds the signed closure") + } + case CheckpointPhase1BeaconRecorded, CheckpointPhase2BeaconRecorded, CheckpointPhase1Sealed, CheckpointPhase2Initialized: + if len(t.Evidence) != 1 { + return errors.New("lifecycle transition requires exactly one payload artifact") + } + case CheckpointFinalReleaseRecorded: + if err := validateFinalReleaseTransitionV4(t); err != nil { + return err + } + case CheckpointFinalCandidateRecorded: + if len(t.Evidence) == 0 { + return errors.New("final transition requires its closed file inventory") + } + case CheckpointReleaseReviewRecorded: + if len(t.Evidence) != 0 { + return errors.New("release review transition adds only the signed operational bundle") + } + default: + return errors.New("unsupported v4 checkpoint transition") + } + } + if t.Record == nil { + return errors.New("transition requires the existing signed protocol record") + } + return t.Record.Validate() +} + +// VerifySignedCheckpointV4 authenticates coordinator state and frozen policy. +// The caller verifies the predecessor edge and the referenced artifact bytes. +// This function deliberately performs no contribution algebra for routine sync. +func VerifySignedCheckpointV4(d CeremonyDefinition, definitionBytes, definitionSignature, checkpointBytes, signature []byte) (CheckpointV4, error) { + var c CheckpointV4 + if err := d.Validate(); err != nil { + return c, err + } + if d.Schema != DefinitionSchemaV4 { + return c, errors.New("checkpoint v4 requires definition v4") + } + key, err := identityPublicKey(d.Coordinator) + if err != nil { + return c, err + } + var actual CeremonyDefinition + if err := VerifySignedRecord(definitionBytes, definitionSignature, &actual, d.Coordinator.KeyID, key); err != nil { + return c, err + } + if !reflect.DeepEqual(actual, d) { + return c, errors.New("supplied definition differs from authenticated bytes") + } + if err := VerifySignedRecord(checkpointBytes, signature, &c, d.Coordinator.KeyID, key); err != nil { + return CheckpointV4{}, err + } + if err := validateCheckpointDefinitionBindingV4(d, definitionBytes, definitionSignature, c); err != nil { + return CheckpointV4{}, err + } + return c, nil +} + +func validateCheckpointDefinitionBindingV4(d CeremonyDefinition, definitionBytes, definitionSignature []byte, c CheckpointV4) error { + if err := d.Validate(); err != nil { + return err + } + if d.Schema != DefinitionSchemaV4 { + return errors.New("checkpoint v4 requires definition v4") + } + if err := c.Validate(); err != nil { + return err + } + if c.CeremonyID != d.CeremonyID || c.Definition.Record.Digest != NewDigest(definitionBytes) || c.Definition.Signature.Digest != NewDigest(definitionSignature) || !reflect.DeepEqual(c.AssurancePolicy, d.AssurancePolicy) || c.ReleaseVerification != d.ReleaseVerification { + return errors.New("checkpoint changed its exact definition or signed policy") + } + if claim := c.Transition.ReplayVerification; claim != nil && !d.Software.AllowsToolBinary(claim.ToolBinary) { + return errors.New("checkpoint replay claim names an unapproved executable") + } + for _, slot := range c.Deliveries { + if err := slot.Scope.ValidateAssignment(d); err != nil { + return err + } + } + if c.Transition.Scope != nil { + if err := c.Transition.Scope.ValidateAssignment(d); err != nil { + return err + } + } + if int(c.Progress.Phase1.AcceptedCount) > len(d.Phase1Policy.Participants) || c.Progress.Phase2 != nil && int(c.Progress.Phase2.AcceptedCount) > len(d.Phase2Policy.Participants) { + return errors.New("checkpoint contribution count exceeds signed schedule") + } + if c.Progress.Phase1Closure != nil && c.Progress.Phase1.AcceptedCount < d.Phase1Policy.Minimum || c.Progress.Phase2Closure != nil && c.Progress.Phase2.AcceptedCount < d.Phase2Policy.Minimum { + return errors.New("closure precedes required contribution minimum") + } + if c.Sequence == 0 && c.Progress.Phase1.HeadPayload != d.Phase1Genesis { + return errors.New("initial checkpoint changed definition genesis payload") + } + return nil +} + +func (p CheckpointProgressV4) currentTurn(scope ContributionScope) error { + if p.Terminal != nil { + return errors.New("ceremony is terminated") + } + state := p.Phase1 + if scope.Phase == Phase1 { + if p.Phase1Closure != nil || p.Phase2 != nil { + return errors.New("phase1 is no longer accepting contributions") + } + } else { + if p.Phase2 == nil || p.Phase2Closure != nil { + return errors.New("phase2 is not accepting contributions") + } + state = *p.Phase2 + } + if int(scope.Index) != int(state.AcceptedCount)+1 || scope.ParentHeadID != state.HeadRecordID { + return errors.New("turn does not follow the exact accepted head") + } + return nil +} + +// ValidateCheckpointTransitionV4 validates a structural edge without replaying +// mathematics. Authoring must first verify the actual signed protocol records +// and perform the existing coordinator verification for candidate acceptance. +// This structs-only check cannot compare predecessor signature bytes. Sync +// callers must use VerifyCheckpointEdgeV4, then hash referenced artifact bytes. +func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { + if err := previous.Validate(); err != nil { + return fmt.Errorf("previous checkpoint: %w", err) + } + if err := next.Validate(); err != nil { + return fmt.Errorf("next checkpoint: %w", err) + } + before, err := MarshalCanonical(previous) + if err != nil { + return err + } + if next.PreviousCheckpoint == nil || next.PreviousCheckpoint.Record.Digest != NewDigest(before) || next.Sequence != previous.Sequence+1 { + return errors.New("checkpoint does not follow its exact predecessor") + } + if previous.CeremonyID != next.CeremonyID || previous.Definition != next.Definition || !reflect.DeepEqual(previous.AssurancePolicy, next.AssurancePolicy) || previous.ReleaseVerification != next.ReleaseVerification { + return errors.New("checkpoint changed immutable ceremony policy") + } + if !artifactSubset(previous.AcceptedArtifacts, next.AcceptedArtifacts) { + return errors.New("accepted artifact inventory must remain append-only") + } + t := next.Transition + if previous.Progress.Terminal != nil { + return errors.New("no transition may follow ceremony termination") + } + if isGovernanceTransitionV4(t.Kind) { + if previous.Progress.FinalRelease != nil { + return errors.New("cannot record governance after final release") + } + if previous.Progress.ReleaseReview != nil && t.Kind == CheckpointIncidentRecorded { + return errors.New("cannot add an incident after freezing release review; abort or restart instead") + } + want := previous.Progress + if t.Kind != CheckpointIncidentRecorded { + want.Terminal = &CheckpointTerminalV4{Kind: governanceKindV4(t.Kind), Record: *t.Record, RestartDefinition: t.RestartDefinition} + } + if !reflect.DeepEqual(want, next.Progress) || !reflect.DeepEqual(previous.Deliveries, next.Deliveries) { + return errors.New("governance changed unrelated progress or delivery history") + } + newRefs := []ArtifactRef{} + for _, ref := range append(signedArtifacts(t.Record), t.Evidence...) { + if !slices.Contains(previous.AcceptedArtifacts, ref) { + newRefs = append(newRefs, ref) + } + } + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, newRefs...) { + return errors.New("governance changed unrelated artifacts") + } + return nil + } + if t.Kind == CheckpointEnrollmentRecorded || t.Kind == CheckpointMirrorRecorded || t.Kind == CheckpointWitnessRecorded || t.Kind == CheckpointAuditRecorded { + if previous.Progress.FinalRelease != nil { + return errors.New("cannot add assurance evidence after final release") + } + if previous.Progress.ReleaseReview != nil { + return errors.New("cannot add assurance evidence after freezing release review") + } + if t.Kind == CheckpointAuditRecorded && (previous.Progress.FinalCandidate == nil || previous.AssurancePolicy.PassingCeremonyAudits == 0) { + return errors.New("audit evidence requires a frozen final candidate and enabled ceremony audits") + } + if !reflect.DeepEqual(previous.Progress, next.Progress) || !reflect.DeepEqual(previous.Deliveries, next.Deliveries) { + return errors.New("evidence edge changed protocol progress or deliveries") + } + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, append(signedArtifacts(t.Record), t.Evidence...)...) { + return errors.New("evidence edge changed unrelated artifacts") + } + return nil + } + if t.Scope != nil { + if t.Scope.CeremonyID != next.CeremonyID { + return errors.New("transition belongs to another ceremony") + } + if err := previous.Progress.currentTurn(*t.Scope); err != nil { + return err + } + return validateV4TurnTransition(previous, next) + } + if !reflect.DeepEqual(previous.Deliveries, next.Deliveries) { + return errors.New("lifecycle edge changed delivery history") + } + for _, s := range previous.Deliveries { + if s.Status == DeliveryAllocated { + return errors.New("resolve allocated deliveries before advancing lifecycle") + } + } + want := previous.Progress + switch t.Kind { + case CheckpointPhase1Closed: + if want.Phase1Closure != nil || want.Phase2 != nil { + return errors.New("phase1 already closed") + } + want.Phase1Closure = t.Record + case CheckpointPhase1BeaconRecorded: + if want.Phase1Closure == nil || want.Phase1Beacon != nil { + return errors.New("phase1 beacon requires unsealed closure") + } + want.Phase1Beacon = t.Record + case CheckpointPhase1Sealed: + if want.Phase1Beacon == nil || want.Phase1Seal != nil { + return errors.New("phase1 seal requires its beacon") + } + want.Phase1Seal = t.Record + case CheckpointPhase2Initialized: + if want.Phase1Seal == nil || want.Phase2 != nil || next.Progress.Phase2 == nil { + return errors.New("phase2 genesis requires sealed phase1 and no existing phase2") + } + state := next.Progress.Phase2 + if state.AcceptedCount != 0 || state.Chain != *t.Record || state.HeadPayload != t.Evidence[0] { + return errors.New("phase2 initialization does not match its signed genesis") + } + want.Phase2 = state + case CheckpointPhase2Closed: + if want.Phase2 == nil || want.Phase2Closure != nil { + return errors.New("phase2 closure requires open phase2") + } + want.Phase2Closure = t.Record + case CheckpointPhase2BeaconRecorded: + if want.Phase2Closure == nil || want.Phase2Beacon != nil { + return errors.New("phase2 beacon requires its closure") + } + want.Phase2Beacon = t.Record + case CheckpointReleaseReviewRecorded: + if want.FinalCandidate == nil || want.ReleaseReview != nil || want.FinalRelease != nil { + return errors.New("release review requires one frozen candidate before final release") + } + want.ReleaseReview = t.Record + case CheckpointFinalCandidateRecorded: + if want.Phase2Beacon == nil || want.FinalCandidate != nil { + return errors.New("final candidate requires both completed phases") + } + want.FinalCandidate = t.Record + case CheckpointFinalReleaseRecorded: + if want.FinalCandidate == nil || want.ReleaseReview == nil || want.FinalRelease != nil { + return errors.New("final release requires a frozen final candidate and signed release review") + } + want.FinalRelease = t.Record + default: + return errors.New("unsupported lifecycle edge") + } + if !reflect.DeepEqual(want, next.Progress) { + return errors.New("lifecycle edge changed unrelated ceremony state") + } + expected := append(signedArtifacts(t.Record), t.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("lifecycle edge published an unexpected artifact set") + } + return nil +} + +func validateV4TurnTransition(previous, next CheckpointV4) error { + t := next.Transition + scope := *t.Scope + wantProgress := previous.Progress + var want []DeliverySlotV2 + var err error + findActive := func(kind CheckpointSubmissionKind) error { + for _, slot := range previous.Deliveries { + if slot.AttemptID == t.AttemptID && slot.Kind == kind && slot.Scope == scope && slot.Status == DeliveryAllocated { + return nil + } + } + return errors.New("transition does not identify its exact active delivery") + } + switch t.Kind { + case CheckpointPhase1CandidateAllocated, CheckpointPhase2CandidateAllocated: + for _, slot := range previous.Deliveries { + if slot.Status == DeliveryAllocated { + return errors.New("another delivery is still active") + } + } + want, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionCandidate, t.AttemptID) + case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + if len(t.Contribution.Files) != 5 { + return errors.New("candidate acceptance requires the fixed five-file inventory") + } + if err = findActive(CheckpointSubmissionCandidate); err != nil { + return err + } + want, err = AdvanceDeliveryV2(previous.Deliveries, t.AttemptID, DeliveryAccepted, t.Contribution) + state := next.Progress.Phase1 + if scope.Phase == Phase2 { + if next.Progress.Phase2 == nil { + return errors.New("candidate acceptance lost phase2") + } + state = *next.Progress.Phase2 + } + if state.AcceptedCount != scope.Index || state.Chain != *t.Record || state.HeadRecordID == scope.ParentHeadID { + return errors.New("candidate acceptance must advance exactly one signed head") + } + base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) + if len(t.Evidence) != len(t.Contribution.Files)+1 { + return errors.New("candidate acceptance requires the complete candidate and coordinator verification") + } + for _, ref := range t.Contribution.Files { + logical := ArtifactRef{Name: base + ref.Name, Digest: ref.Digest} + if !slices.Contains(t.Evidence, logical) { + return errors.New("accepted evidence differs from complete contribution inventory") + } + if ref.Name == "contribution.bin" && state.HeadPayload != logical { + return errors.New("accepted payload differs from candidate bytes") + } + } + if !slices.ContainsFunc(t.Evidence, func(ref ArtifactRef) bool { return ref.Name == base+"verification.json" }) { + return errors.New("candidate acceptance lacks coordinator verification record") + } + if scope.Phase == Phase1 { + wantProgress.Phase1 = state + } else { + wantProgress.Phase2 = &state + } + case CheckpointDeliveryRetired, CheckpointContributionRejected: + if err = findActive(CheckpointSubmissionCandidate); err != nil { + return err + } + status := DeliveryRetired + if t.Kind == CheckpointContributionRejected { + status = DeliveryRejected + } + want, err = AdvanceDeliveryV2(previous.Deliveries, t.AttemptID, status, t.Contribution) + if err == nil && t.NextAttemptID != "" { + want, err = AllocateDeliveryV2(want, scope, CheckpointSubmissionCandidate, t.NextAttemptID) + } + case CheckpointDeliveryReallocated: + index := -1 + for i, slot := range previous.Deliveries { + if slot.AttemptID == t.AttemptID { + index = i + } + } + if index < 0 { + return errors.New("replacement must name a retained terminal delivery") + } + old := previous.Deliveries[index] + if old.Scope != scope || (old.Status != DeliveryRetired && old.Status != DeliveryRejected) { + return errors.New("replacement requires the same retired or rejected scope") + } + for _, slot := range previous.Deliveries[index+1:] { + if slot.Scope == scope && slot.Kind == old.Kind { + return errors.New("replacement must follow the most recent delivery for this submission") + } + } + want, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionCandidate, t.NextAttemptID) + default: + return errors.New("unsupported turn transition") + } + if err != nil { + return err + } + if !reflect.DeepEqual(want, next.Deliveries) { + return errors.New("turn edge changed unexpected delivery history") + } + if !reflect.DeepEqual(wantProgress, next.Progress) { + return errors.New("turn edge changed unrelated ceremony state") + } + expected := append(signedArtifacts(t.Record), t.Evidence...) + if !exactArtifactDelta(previous.AcceptedArtifacts, next.AcceptedArtifacts, expected...) { + return errors.New("turn edge published an unexpected artifact set") + } + return nil +} + +// VerifyCheckpointEdgeV4 authenticates both exact checkpoint/signature pairs, +// checks both predecessor digests, and verifies the structural state change. +// Referenced protocol files and math are separate explicit verification layers. +func VerifyCheckpointEdgeV4(d CeremonyDefinition, definitionBytes, definitionSignature, previousBytes, previousSignature, nextBytes, nextSignature []byte) (CheckpointV4, error) { + previous, err := VerifySignedCheckpointV4(d, definitionBytes, definitionSignature, previousBytes, previousSignature) + if err != nil { + return CheckpointV4{}, fmt.Errorf("previous checkpoint: %w", err) + } + next, err := VerifySignedCheckpointV4(d, definitionBytes, definitionSignature, nextBytes, nextSignature) + if err != nil { + return CheckpointV4{}, fmt.Errorf("next checkpoint: %w", err) + } + if next.PreviousCheckpoint == nil || next.PreviousCheckpoint.Record.Digest != NewDigest(previousBytes) || next.PreviousCheckpoint.Signature.Digest != NewDigest(previousSignature) { + return CheckpointV4{}, errors.New("checkpoint does not reference the exact predecessor record and signature") + } + if err := ValidateCheckpointTransitionV4(previous, next); err != nil { + return CheckpointV4{}, err + } + return next, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_audits.go b/internal/mpcceremony/checkpoint_v4_audits.go new file mode 100644 index 00000000..42d4f5e3 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_audits.go @@ -0,0 +1,50 @@ +package mpcceremony + +import ( + "errors" + "time" +) + +// Collection does not repeat auditor mathematics; the existing signed audit +// asserts that replay. Release still checks the full signed audit minimum. +func verifyCheckpointAuditsV4(reader *checkpointReaderV4, d CeremonyDefinition, p CheckpointProgressV4, enrollments map[string]EnrollmentRecord, refs []SignedArtifactRefs, requireMinimum bool) (time.Time, error) { + if p.FinalCandidate == nil { + return time.Time{}, errors.New("audit collection requires a frozen final candidate") + } + if len(refs) > MaxAuditors { + return time.Time{}, errors.New("audit collection exceeds supported auditor count") + } + rb, sb, err := reader.pair(*p.FinalCandidate) + if err != nil { + return time.Time{}, err + } + key, err := identityPublicKey(d.Coordinator) + if err != nil { + return time.Time{}, err + } + var candidate CandidateMetadata + if err = VerifySignedRecord(rb, sb, &candidate, d.Coordinator.KeyID, key); err != nil { + return time.Time{}, err + } + if candidate.CeremonyID != d.CeremonyID { + return time.Time{}, errors.New("audit candidate belongs to another ceremony") + } + inputs := make([]signedAuditInput, 0, len(refs)) + for _, ref := range refs { + raw, sig, err := reader.pair(ref) + if err != nil { + return time.Time{}, err + } + var record AuditRecord + if err = UnmarshalCanonical(raw, &record); err != nil { + return time.Time{}, err + } + enrollment, ok := enrollments[record.AuditorID] + if !ok || enrollment.Role != EnrollmentAuditor || enrollment.Identity.KeyID != record.AuditorKeyID { + return time.Time{}, errors.New("audit signer requires its committed auditor enrollment") + } + inputs = append(inputs, signedAuditInput{record: raw, signature: sig, name: ref.Record.Name}) + } + _, latest, err := verifyAuditCollection(d, candidate, inputs, requireMinimum) + return latest, err +} diff --git a/internal/mpcceremony/checkpoint_v4_audits_test.go b/internal/mpcceremony/checkpoint_v4_audits_test.go new file mode 100644 index 00000000..8a57ec30 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_audits_test.go @@ -0,0 +1,65 @@ +package mpcceremony + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCheckpointV4AuditCollectionPreservesQuorumAndBinding(t *testing.T) { + d := adversarialDefinition(t) + d.Schema = DefinitionSchemaV4 + d.ReleaseVerification = CoordinatorReplayReleaseV1 + d.AssurancePolicy = &AssurancePolicy{PassingCeremonyAudits: 2} + var err error + d, err = FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + c := adversarialCandidate(t, d) + cb, err := MarshalCanonical(c) + if err != nil { + t.Fatal(err) + } + outputs := candidateAuditOutputs(c, ArtifactRef{Name: CandidateMetadataFile, Digest: NewDigest(cb)}) + inputs := []signedAuditInput{} + for i := 0; i < 2; i++ { + a := adversarialSignedAudit(t, d, c, i, "2026-07-23T14:00:00Z", outputs) + rb, err := os.ReadFile(a.RecordPath) + if err != nil { + t.Fatal(err) + } + sb, err := os.ReadFile(a.SignaturePath) + if err != nil { + t.Fatal(err) + } + inputs = append(inputs, signedAuditInput{record: rb, signature: sb, name: filepath.Base(a.RecordPath)}) + } + if _, _, err = verifyAuditCollection(d, c, inputs[:1], false); err != nil { + t.Fatal(err) + } + if _, _, err = verifyAuditCollection(d, c, inputs[:1], true); err == nil { + t.Fatal("partial collection passed release minimum") + } + if _, _, err = verifyAuditCollection(d, c, inputs, true); err != nil { + t.Fatal(err) + } + if _, _, err = verifyAuditCollection(d, c, []signedAuditInput{inputs[0], inputs[0]}, false); err == nil { + t.Fatal("duplicate auditor accepted") + } + wrong := c + wrong.FinalizedAt = "2026-07-23T13:00:01Z" + if _, _, err = verifyAuditCollection(d, wrong, inputs, false); err == nil { + t.Fatal("audit accepted for a different candidate") + } + disabled := d + disabled.AssurancePolicy = &AssurancePolicy{} + if _, _, err = verifyAuditCollection(disabled, c, inputs[:1], false); err == nil { + t.Fatal("disabled audits accepted during collection") + } + broken := append([]signedAuditInput{}, inputs...) + broken[0].signature = []byte("invalid") + if _, _, err = verifyAuditCollection(d, c, broken, false); err == nil { + t.Fatal("invalid signature accepted during collection") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_beacon_evidence.go b/internal/mpcceremony/checkpoint_v4_beacon_evidence.go new file mode 100644 index 00000000..93945a14 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_beacon_evidence.go @@ -0,0 +1,74 @@ +package mpcceremony + +import ( + "errors" +) + +func checkpointClosureV4(reader *checkpointReaderV4, d CeremonyDefinition, p CheckpointProgressV4, phase Phase) (CloseRecord, []byte, ArtifactRef, error) { + refs := p.Phase1Closure + if phase == Phase2 { + refs = p.Phase2Closure + } else if phase != Phase1 { + return CloseRecord{}, nil, ArtifactRef{}, errors.New("invalid evidence phase") + } + if refs == nil { + return CloseRecord{}, nil, ArtifactRef{}, errors.New("evidence requires the committed phase closure") + } + rb, sb, err := reader.pair(*refs) + if err != nil { + return CloseRecord{}, nil, ArtifactRef{}, err + } + key, err := identityPublicKey(d.Coordinator) + if err != nil { + return CloseRecord{}, nil, ArtifactRef{}, err + } + var close CloseRecord + err = VerifySignedRecord(rb, sb, &close, d.Coordinator.KeyID, key) + return close, rb, refs.Record, err +} + +func verifyCheckpointWitnessesV4(reader *checkpointReaderV4, d CeremonyDefinition, p CheckpointProgressV4, enrollments map[string]EnrollmentRecord, refs []SignedArtifactRefs) (map[Phase]int, error) { + counts := map[Phase]int{} + if d.AssurancePolicy.PublicWitnessesPerPhase == 0 && len(refs) > 0 { + return nil, errors.New("witness evidence is disabled by signed policy") + } + groups := map[Phase][]SignedPublicWitness{} + for _, pair := range refs { + rb, sb, err := reader.pair(pair) + if err != nil { + return nil, err + } + var receipt PublicWitnessReceipt + if err = UnmarshalCanonical(rb, &receipt); err != nil { + return nil, err + } + enrollment, ok := enrollments[receipt.Witness.ID] + if !ok || enrollment.Role != EnrollmentPublicWitness || enrollment.Identity != receipt.Witness { + return nil, errors.New("witness has no matching committed enrollment") + } + key, err := identityPublicKey(enrollment.Identity) + if err != nil { + return nil, err + } + _, _, closureRef, err := checkpointClosureV4(reader, d, p, receipt.Phase) + if err != nil { + return nil, err + } + if receipt.Closure != closureRef { + return nil, errors.New("witness names another closure artifact") + } + groups[receipt.Phase] = append(groups[receipt.Phase], SignedPublicWitness{RecordBytes: rb, SignatureBytes: sb, TrustedKey: key}) + } + for phase, receipts := range groups { + closure, bytes, _, err := checkpointClosureV4(reader, d, p, phase) + if err != nil { + return nil, err + } + // Allow partial collection; the lifecycle gate enforces the full minimum. + if err = VerifyPublicWitnessQuorum(d, closure, bytes, receipts, 1); err != nil { + return nil, err + } + counts[phase] = len(receipts) + } + return counts, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_bundle.go b/internal/mpcceremony/checkpoint_v4_bundle.go new file mode 100644 index 00000000..84df7b66 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_bundle.go @@ -0,0 +1,189 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "slices" + "strings" + "time" +) + +type OperationalBundlePreparationV4 struct { + SourceCheckpoint SignedArtifactRefs `json:"source_checkpoint"` + Bundle OperationalEvidenceBundle `json:"bundle"` +} + +func (p OperationalBundlePreparationV4) Validate() error { + if err := p.SourceCheckpoint.Validate(); err != nil { + return err + } + return p.Bundle.Validate() +} + +// PrepareOperationalBundleV4 reads only the exact authenticated history. It +// neither discovers loose files nor signs, uploads or replays contributions. +// The eventual signer must rederive against the same exact predecessor. +func PrepareOperationalBundleV4(trust TrustPaths, artifactRoot string, head SignedArtifactRefs, assembledAt time.Time) (OperationalBundlePreparationV4, error) { + if assembledAt.IsZero() || assembledAt.Location() != time.UTC { + return OperationalBundlePreparationV4{}, errors.New("assembled_at must be a nonzero UTC time") + } + trusted, err := loadOperationalCeremony(trust) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + db, err := MarshalCanonical(trusted.Definition) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + ds, err := readRegularBounded(trust.DefinitionSignaturePath, 4096) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + reader, err := openCheckpointReaderV4(artifactRoot) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + defer func() { _ = reader.root.Close() }() + ancestry, err := loadCheckpointAncestryV4(reader, trusted.Definition, db, ds, head) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + bundle, err := deriveOperationalBundleV4(reader, trusted, db, ancestry, assembledAt) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + raw, err := MarshalCanonical(bundle) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + first, err := LoadAuthenticatedCloseEvidence(reader.path, bundle.Phase1.Close) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + second, err := LoadAuthenticatedCloseEvidence(reader.path, bundle.Phase2.Close) + if err != nil { + return OperationalBundlePreparationV4{}, err + } + if err = VerifyOperationalEvidenceDraft(VerifyOperationalEvidenceOptions{Definition: trusted.Definition, CoordinatorPublicKey: trusted.CoordinatorPublicKey, EvidenceRoot: reader.path, BundleBytes: raw, Phase1Close: first, Phase2Close: second}); err != nil { + return OperationalBundlePreparationV4{}, fmt.Errorf("derived bundle verification: %w", err) + } + return OperationalBundlePreparationV4{SourceCheckpoint: head, Bundle: bundle}, nil +} + +func sortedSignedRefsV4(refs []SignedArtifactRefs) []SignedArtifactRefs { + result := append([]SignedArtifactRefs{}, refs...) + slices.SortFunc(result, func(a, b SignedArtifactRefs) int { return strings.Compare(a.Record.Name, b.Record.Name) }) + return result +} + +func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCeremony, db []byte, a checkpointAncestryV4, at time.Time) (OperationalEvidenceBundle, error) { + p := a.head.Progress + d := trusted.Definition + if p.FinalCandidate == nil || p.FinalRelease != nil || p.Terminal != nil { + return OperationalEvidenceBundle{}, errors.New("bundle preparation requires a frozen candidate before final release") + } + enrollments, err := loadCheckpointEnrollmentsV4(reader, d, db, a.enrollments) + if err != nil { + return OperationalEvidenceBundle{}, err + } + for _, identity := range append([]Identity{d.Coordinator, d.ReleaseSigner}, preparationRoster(d)...) { + if _, ok := enrollments[identity.ID]; !ok { + return OperationalEvidenceBundle{}, fmt.Errorf("required proof-of-possession enrollment for %q is missing", identity.ID) + } + } + if _, err = verifyCheckpointMirrorsV4(reader, d, db, a.accepted, enrollments, a.mirrors); err != nil { + return OperationalEvidenceBundle{}, err + } + if _, err = verifyCheckpointWitnessesV4(reader, d, p, enrollments, a.witnesses); err != nil { + return OperationalEvidenceBundle{}, err + } + read := func(refs SignedArtifactRefs, out any) error { + rb, _, err := reader.pair(refs) + if err != nil { + return err + } + return UnmarshalCanonical(rb, out) + } + witnesses := map[Phase][]SignedArtifactRefs{} + mirrors := map[Phase]map[uint8][]SignedArtifactRefs{Phase1: {}, Phase2: {}} + beacons := map[Phase]SignedArtifactRefs{} + raws := map[Phase]ArtifactRef{} + for _, refs := range a.witnesses { + var r PublicWitnessReceipt + if err = read(refs, &r); err != nil { + return OperationalEvidenceBundle{}, err + } + witnesses[r.Phase] = append(witnesses[r.Phase], refs) + } + for _, refs := range a.mirrors { + var r ImmutableMirrorReceipt + if err = read(refs, &r); err != nil { + return OperationalEvidenceBundle{}, err + } + mirrors[r.Phase][r.Index] = append(mirrors[r.Phase][r.Index], refs) + } + for phase, refs := range map[Phase]*SignedArtifactRefs{Phase1: p.Phase1Beacon, Phase2: p.Phase2Beacon} { + if refs == nil { + return OperationalEvidenceBundle{}, fmt.Errorf("%s signed beacon is missing", phase) + } + var r BeaconRecord + if err = read(*refs, &r); err != nil { + return OperationalEvidenceBundle{}, err + } + if r.Phase != phase { + return OperationalEvidenceBundle{}, fmt.Errorf("%s signed beacon has wrong phase", phase) + } + beacons[phase] = *refs + raws[phase] = r.RawResponse + } + bundle := OperationalEvidenceBundle{Schema: OperationalEvidenceBundleSchemaV4, CeremonyID: d.CeremonyID, AssurancePolicy: cloneAssurancePolicy(d.AssurancePolicy), Enrollments: sortedSignedRefsV4(a.enrollments), GovernanceRecords: []SignedArtifactRefs{}, CoordinatorID: d.Coordinator.ID, CoordinatorKeyID: d.Coordinator.KeyID, AssembledAt: at.Format(time.RFC3339Nano)} + used := 0 + for _, incident := range a.incidents { + if _, err := verifyGovernanceRecordV4(reader, d, incident); err != nil { + return OperationalEvidenceBundle{}, err + } + bundle.GovernanceRecords = append(bundle.GovernanceRecords, *incident.Record) + } + bundle.GovernanceRecords = sortedSignedRefsV4(bundle.GovernanceRecords) + for _, phase := range []Phase{Phase1, Phase2} { + state := p.Phase1 + close := p.Phase1Closure + if phase == Phase2 { + state = *p.Phase2 + close = p.Phase2Closure + } + var chain Chain + cb, cs, err := reader.pair(state.Chain) + if err != nil { + return OperationalEvidenceBundle{}, err + } + if err = VerifySignedRecord(cb, cs, &chain, d.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { + return OperationalEvidenceBundle{}, err + } + if err = chain.ValidateAgainstDefinition(d); err != nil { + return OperationalEvidenceBundle{}, err + } + if err = verifyV4ChainProjection(chain, state.Chain, state); err != nil { + return OperationalEvidenceBundle{}, err + } + pe := PhaseOperationalEvidence{Phase: phase, AcceptedChain: state.Chain, Close: *close, AcceptedHeads: []AcceptedHeadOperationalEvidence{}, PublicWitnessQuorum: d.AssurancePolicy.PublicWitnessesPerPhase, PublicWitnessReceipts: sortedSignedRefsV4(witnesses[phase]), Beacon: beacons[phase], RawBeaconResponses: []ArtifactRef{raws[phase]}} + for _, record := range chain.Records { + scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: phase, Index: record.Index, ParticipantID: record.ParticipantID, ParentHeadID: record.PreviousRecordID} + tx, ok := a.acceptedTransitions[scope] + if !ok { + return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d lacks its accepted checkpoint", phase, record.Index) + } + used++ + pe.AcceptedHeads = append(pe.AcceptedHeads, AcceptedHeadOperationalEvidence{Index: record.Index, PredecessorHeadID: record.PreviousRecordID, AcceptedHeadID: record.RecordID, AcceptedChainPrefix: *tx.Record, MirrorReceipts: sortedSignedRefsV4(mirrors[phase][record.Index])}) + } + if phase == Phase1 { + bundle.Phase1 = pe + } else { + bundle.Phase2 = pe + } + } + if used != len(a.acceptedTransitions) { + return OperationalEvidenceBundle{}, errors.New("accepted checkpoints do not match the final chains one for one") + } + return bundle, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_bundle_test.go b/internal/mpcceremony/checkpoint_v4_bundle_test.go new file mode 100644 index 00000000..c0de1bc8 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_bundle_test.go @@ -0,0 +1,24 @@ +package mpcceremony + +import ( + "strings" + "testing" + "time" +) + +func TestOperationalBundleV4RequiresUnreleasedFrozenCandidate(t *testing.T) { + for name, progress := range map[string]CheckpointProgressV4{ + "no candidate": {}, + "already released": {FinalCandidate: &SignedArtifactRefs{}, FinalRelease: &SignedArtifactRefs{}}, + "terminated": {FinalCandidate: &SignedArtifactRefs{}, Terminal: &CheckpointTerminalV4{Kind: GovernanceAbort}}, + } { + t.Run(name, func(t *testing.T) { + // The gate must run before attempting any artifact access. + bundle, err := deriveOperationalBundleV4(nil, &TrustedCeremony{}, nil, + checkpointAncestryV4{head: CheckpointV4{Progress: progress}}, time.Now().UTC()) + if err == nil || !strings.Contains(err.Error(), "frozen candidate before final release") || bundle.Schema != "" { + t.Fatalf("invalid state produced bundle: %+v, %v", bundle, err) + } + }) + } +} diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go new file mode 100644 index 00000000..950da5db --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -0,0 +1,105 @@ +package mpcceremony + +import ( + "errors" + "slices" + "strings" +) + +// CheckpointCommitmentsV4 locates coordinator-committed records. It does not +// assert that those records, their signatures or their payloads were re-read. +// The containing inspection binds this index to its exact verified head pair. +type CheckpointCommitmentsV4 struct { + Enrollments []SignedArtifactRefs `json:"enrollments"` + Turns []TurnCommitmentV4 `json:"turns"` + FinalReleaseArtifacts []ArtifactRef `json:"final_release_artifacts"` +} + +type CandidateAllocationV4 struct { + CheckpointSequence uint64 `json:"checkpoint_sequence"` + Checkpoint SignedArtifactRefs `json:"checkpoint"` + AttemptID string `json:"attempt_id"` + AllocatedAt string `json:"allocated_at"` +} + +type AcceptedChainCommitmentV4 struct { + AttemptID string `json:"attempt_id"` + ContributionResultID string `json:"contribution_result_id"` + Pair SignedArtifactRefs `json:"pair"` +} + +type TurnCommitmentV4 struct { + Scope ContributionScope `json:"scope"` + Allocations []CandidateAllocationV4 `json:"allocations"` + AcceptedChain *AcceptedChainCommitmentV4 `json:"accepted_chain,omitempty"` +} + +func collectTurnCommitmentV4(turns map[ContributionScope]*TurnCommitmentV4, c CheckpointV4, refs SignedArtifactRefs) error { + if err := refs.Validate(); err != nil { + return err + } + t := c.Transition + switch t.Kind { + case CheckpointPhase1CandidateAllocated, CheckpointPhase2CandidateAllocated, CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + default: + return nil + } + scope := *t.Scope + turn := turns[scope] + if turn == nil { + if len(turns) >= 2*MaxParticipants { + return errors.New("turn commitment index exceeds protocol capacity") + } + turn = &TurnCommitmentV4{Scope: scope, Allocations: []CandidateAllocationV4{}} + turns[scope] = turn + } + switch t.Kind { + case CheckpointPhase1CandidateAllocated, CheckpointPhase2CandidateAllocated: + if len(turn.Allocations) >= MaxDeliveryAttemptsPerSubmissionV2 { + return errors.New("candidate allocation index exceeds attempt limit") + } + turn.Allocations = append(turn.Allocations, CandidateAllocationV4{CheckpointSequence: c.Sequence, Checkpoint: refs, AttemptID: t.AttemptID, AllocatedAt: t.AllocatedAt}) + case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + if turn.AcceptedChain != nil { + return errors.New("duplicate candidate commitment") + } + resultID, err := t.Contribution.ID() + if err != nil { + return err + } + turn.AcceptedChain = &AcceptedChainCommitmentV4{AttemptID: t.AttemptID, ContributionResultID: resultID, Pair: *t.Record} + } + return nil +} + +// InspectStoredCheckpointV4 shares the structural verifier's exact ancestry +// read. No loose directory scan or latest-transition heuristic defines facts. +func InspectStoredCheckpointV4(trust TrustPaths, root string, head SignedArtifactRefs) (CheckpointV4, CheckpointCommitmentsV4, error) { + c, err := openStoredCheckpointV4(trust, root, head) + if err != nil { + return CheckpointV4{}, CheckpointCommitmentsV4{}, err + } + defer func() { _ = c.reader.root.Close() }() + index, err := checkpointCommitmentsV4(c.ancestry) + if err == nil { + index.FinalReleaseArtifacts, err = finalReleaseDownloadArtifactsV4(c.reader, c.ancestry) + } + return c.ancestry.head, index, err +} + +func checkpointCommitmentsV4(a checkpointAncestryV4) (CheckpointCommitmentsV4, error) { + if len(a.enrollments) > 128 { + return CheckpointCommitmentsV4{}, errors.New("enrollment commitment index exceeds protocol capacity") + } + index := CheckpointCommitmentsV4{Enrollments: sortedSignedRefsV4(a.enrollments), Turns: []TurnCommitmentV4{}, FinalReleaseArtifacts: []ArtifactRef{}} + for _, turn := range a.turnCommitments { + index.Turns = append(index.Turns, *turn) + } + slices.SortFunc(index.Turns, func(a, b TurnCommitmentV4) int { + if a.Scope.Phase != b.Scope.Phase { + return strings.Compare(string(a.Scope.Phase), string(b.Scope.Phase)) + } + return int(a.Scope.Index) - int(b.Scope.Index) + }) + return index, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_commitments_test.go b/internal/mpcceremony/checkpoint_v4_commitments_test.go new file mode 100644 index 00000000..980087d8 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_commitments_test.go @@ -0,0 +1,107 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" +) + +func storeCommitmentSequenceV4(t *testing.T, d CeremonyDefinition, db, ds []byte, root string, sequence []CheckpointV4) (TrustPaths, SignedArtifactRefs) { + t.Helper() + putCheckpointTestFileV4(t, root, "ceremony.json", db) + putCheckpointTestFileV4(t, root, "ceremony.sig", ds) + key := adversarialPrivateKey(1) + anchor := filepath.Join(t.TempDir(), "coordinator.hex") + if err := os.WriteFile(anchor, []byte(hex.EncodeToString(key.Public().(ed25519.PublicKey))), 0600); err != nil { + t.Fatal(err) + } + trust := TrustPaths{DefinitionPath: filepath.Join(root, "ceremony.json"), DefinitionSignaturePath: filepath.Join(root, "ceremony.sig"), CoordinatorPublicKeyPath: anchor} + var head SignedArtifactRefs + for n := range sequence { + if n > 0 { + previous := head + sequence[n].PreviousCheckpoint = &previous + } + head = putCheckpointTestPairV4(t, root, fmt.Sprintf("checkpoints/%04d", n), sequence[n], d.Coordinator.KeyID, key) + } + return trust, head +} + +func TestCheckpointCommitmentsSurviveUnrelatedEdges(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + sequence := checkpointTurnV4(t, d, initial, Phase1) + roster := checkpointSigned("enrollments/coordinator") + evidence := checkpointArtifact("enrollments/disclosure.txt", "public") + next := nextCheckpointV4(t, sequence[len(sequence)-1], CheckpointTransitionV4{Kind: CheckpointEnrollmentRecorded, Record: &roster, Evidence: []ArtifactRef{evidence}}) + sequence = append(sequence, next) + root := t.TempDir() + trust, head := storeCommitmentSequenceV4(t, d, db, ds, root, sequence) + c, index, err := InspectStoredCheckpointV4(trust, root, head) + if err != nil { + t.Fatal(err) + } + if c.Sequence != 3 || len(index.Enrollments) != 1 || index.Enrollments[0] != roster || len(index.Turns) != 1 { + t.Fatalf("lost facts: %+v", index) + } + turn := index.Turns[0] + if len(turn.Allocations) != 1 || turn.Allocations[0].AttemptID != sequence[1].Transition.AttemptID || turn.Allocations[0].CheckpointSequence != 1 || turn.AcceptedChain == nil || turn.AcceptedChain.Pair != *sequence[2].Transition.Record { + t.Fatalf("incomplete turn: %+v", turn) + } + if sequence[2].PreviousCheckpoint == nil || turn.Allocations[0].Checkpoint != *sequence[2].PreviousCheckpoint { + t.Fatalf("allocation lost its exact signed checkpoint pair: %+v", turn.Allocations[0]) + } + if _, err := os.Stat(filepath.Join(root, roster.Record.Name)); !os.IsNotExist(err) { + t.Fatal("unexpected enrollment bytes") + } +} + +func TestCheckpointCommitmentsRetainCandidateAllocations(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + first := checkpointTurnV4(t, d, initial, Phase1)[1] + scope := *first.Transition.Scope + retired := nextCheckpointV4(t, first, CheckpointTransitionV4{Kind: CheckpointDeliveryRetired, Scope: &scope, AttemptID: first.Transition.AttemptID, Evidence: []ArtifactRef{}}) + var err error + retired.Deliveries, err = AdvanceDeliveryV2(first.Deliveries, first.Transition.AttemptID, DeliveryRetired, nil) + if err != nil { + t.Fatal(err) + } + replacement := fmt.Sprintf("%032x", 999) + second := nextCheckpointV4(t, retired, CheckpointTransitionV4{Kind: CheckpointPhase1CandidateAllocated, Scope: &scope, AttemptID: replacement, AllocatedAt: "2026-01-01T00:02:00Z", Evidence: []ArtifactRef{}}) + second.Deliveries, err = AllocateDeliveryV2(retired.Deliveries, scope, CheckpointSubmissionCandidate, replacement) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + trust, head := storeCommitmentSequenceV4(t, d, db, ds, root, []CheckpointV4{initial, first, retired, second}) + _, index, err := InspectStoredCheckpointV4(trust, root, head) + if err != nil { + t.Fatal(err) + } + if len(index.Turns) != 1 || len(index.Turns[0].Allocations) != 2 || index.Turns[0].Allocations[0].AttemptID != replacement || index.Turns[0].Allocations[1].AttemptID != first.Transition.AttemptID { + t.Fatalf("allocation history lost: %+v", index) + } + _, repeated, err := InspectStoredCheckpointV4(trust, root, head) + if err != nil || !reflect.DeepEqual(index, repeated) { + t.Fatal("index is not deterministic", err) + } +} + +func TestTurnCommitmentBoundsV4(t *testing.T) { + d, initial, _, _ := checkpointFixtureV4(t) + tx := checkpointTurnV4(t, d, initial, Phase1)[1] + turns := map[ContributionScope]*TurnCommitmentV4{} + for n := 0; n < MaxDeliveryAttemptsPerSubmissionV2; n++ { + tx.Sequence = uint64(MaxDeliveryAttemptsPerSubmissionV2 - n) + tx.Transition.AttemptID = fmt.Sprintf("%032x", n+1) + if err := collectTurnCommitmentV4(turns, tx, checkpointSigned(fmt.Sprintf("checkpoints/%04d", n))); err != nil { + t.Fatal(err) + } + } + if err := collectTurnCommitmentV4(turns, tx, checkpointSigned("checkpoints/overflow")); err == nil { + t.Fatal("allocation bound not enforced") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_discovery.go b/internal/mpcceremony/checkpoint_v4_discovery.go new file mode 100644 index 00000000..12223985 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_discovery.go @@ -0,0 +1,64 @@ +package mpcceremony + +import "errors" + +// CheckpointDiscoveryV4 is only a signed discovery hint. It deliberately omits +// cumulative payload inventories. Legal ancestry must still be verified using +// VerifyStoredCheckpointV4 before the caller uses progress or deliveries. +type CheckpointDiscoveryV4 struct { + CeremonyID string `json:"ceremony_id"` + Sequence uint64 `json:"sequence"` + PreviousCheckpoint *SignedArtifactRefs `json:"previous_checkpoint,omitempty"` + VerificationDependencies []ArtifactRef `json:"verification_dependencies"` + // Optional guidance dependency, not needed by structural verification. + Enrollment *SignedArtifactRefs `json:"enrollment,omitempty"` +} + +// DiscoverSignedCheckpointV4 authenticates one exact pair, without loading its +// predecessors. Dependencies are precisely the extra files read for this edge +// by the stored ancestry verifier (governance only), not all accepted artifacts. +func DiscoverSignedCheckpointV4(d CeremonyDefinition, definition, definitionSignature, record, signature []byte) (CheckpointDiscoveryV4, error) { + c, err := VerifySignedCheckpointV4(d, definition, definitionSignature, record, signature) + if err != nil { + return CheckpointDiscoveryV4{}, err + } + r := CheckpointDiscoveryV4{CeremonyID: c.CeremonyID, Sequence: c.Sequence, PreviousCheckpoint: c.PreviousCheckpoint, VerificationDependencies: []ArtifactRef{}} + if c.Transition.Kind == CheckpointEnrollmentRecorded { + pair := *c.Transition.Record + if pair.Record.Digest.Size > maxSignedRecordBytes || pair.Signature.Digest.Size > 4096 { + return CheckpointDiscoveryV4{}, errors.New("enrollment discovery pair exceeds metadata limit") + } + r.Enrollment = &pair + } + if !isGovernanceTransitionV4(c.Transition.Kind) { + return r, nil + } + add := func(ref ArtifactRef, limit int64) error { + if ref.Digest.Size <= 0 || ref.Digest.Size > limit { + return errors.New("checkpoint discovery dependency exceeds verification limit") + } + r.VerificationDependencies = append(r.VerificationDependencies, ref) + return nil + } + if err := add(c.Transition.Record.Record, maxSignedRecordBytes); err != nil { + return CheckpointDiscoveryV4{}, err + } + if err := add(c.Transition.Record.Signature, 4096); err != nil { + return CheckpointDiscoveryV4{}, err + } + for _, ref := range c.Transition.Evidence { + limit := int64(1 << 20) + if next := c.Transition.RestartDefinition; next != nil { + if ref == next.Record { + limit = maxSignedRecordBytes + } + if ref == next.Signature { + limit = 4096 + } + } + if err := add(ref, limit); err != nil { + return CheckpointDiscoveryV4{}, err + } + } + return r, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_discovery_test.go b/internal/mpcceremony/checkpoint_v4_discovery_test.go new file mode 100644 index 00000000..9cb1e0aa --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_discovery_test.go @@ -0,0 +1,215 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" +) + +func TestCheckpointDiscoveryV4OnlyRequestsAncestryDependencies(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, initial, Phase1) + key := adversarialPrivateKey(1) + for _, c := range turn { + raw, sig, err := SignRecord(c, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + got, err := DiscoverSignedCheckpointV4(d, db, ds, raw, sig) + if err != nil { + t.Fatal(err) + } + if got.CeremonyID != d.CeremonyID || got.Sequence != c.Sequence || !reflect.DeepEqual(got.PreviousCheckpoint, c.PreviousCheckpoint) || got.VerificationDependencies == nil || len(got.VerificationDependencies) != 0 { + t.Fatalf("unexpected discovery %+v", got) + } + // Nothing has been written to disk: a valid signature alone discovers + // links, but cannot establish that the ancestor or payload even exists. + if _, err := DiscoverSignedCheckpointV4(d, db, ds, append(raw, '\n'), sig); err == nil { + t.Fatal("tampered checkpoint accepted") + } + wrong := append([]byte(nil), sig...) + wrong[0] ^= 1 + if _, err := DiscoverSignedCheckpointV4(d, db, ds, raw, wrong); err == nil { + t.Fatal("tampered signature accepted") + } + } + for _, kind := range []CheckpointTransitionKind{CheckpointIncidentRecorded, CheckpointAborted, CheckpointRestarted} { + pair := checkpointSigned("governance/record") + tx := CheckpointTransitionV4{Kind: kind, Record: &pair, Evidence: checkpointArtifacts(checkpointArtifact("governance/statement.txt", "statement"))} + if kind == CheckpointRestarted { + next := checkpointSigned("restart/ceremony") + tx.RestartDefinition = &next + tx.Evidence = appendCheckpointArtifacts(tx.Evidence, next.Record, next.Signature) + } + c := nextCheckpointV4(t, initial, tx) + if kind != CheckpointIncidentRecorded { + c.Progress.Terminal = &CheckpointTerminalV4{Kind: governanceKindV4(kind), Record: pair, RestartDefinition: tx.RestartDefinition} + } + raw, sig, err := SignRecord(c, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + got, err := DiscoverSignedCheckpointV4(d, db, ds, raw, sig) + if err != nil { + t.Fatal(err) + } + want := append([]ArtifactRef{pair.Record, pair.Signature}, tx.Evidence...) + if !reflect.DeepEqual(got.VerificationDependencies, want) { + t.Fatalf("%s: %+v", kind, got) + } + } +} + +func TestCheckpointDiscoveryV4CompleteStoredDependencyContract(t *testing.T) { + for _, kind := range []CheckpointTransitionKind{CheckpointPhase1CandidateAccepted, CheckpointIncidentRecorded, CheckpointAborted, CheckpointRestarted} { + t.Run(string(kind), func(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + key := adversarialPrivateKey(1) + source, stage := t.TempDir(), t.TempDir() + sequence := []CheckpointV4{initial} + if kind == CheckpointPhase1CandidateAccepted { + sequence = checkpointTurnV4(t, d, initial, Phase1) + } else { + statement := putCheckpointTestFileV4(t, source, "governance/statement.txt", []byte("Public fixture statement.\n")) + evidence := []ArtifactRef{statement} + created, _ := time.Parse(time.RFC3339Nano, d.CreatedAt) + record := GovernanceRecord{Schema: GovernanceRecordSchema, Kind: governanceKindV4(kind), CeremonyID: d.CeremonyID, Phase: Phase1, Index: 1, HeadID: initial.Progress.Phase1.HeadRecordID, Evidence: evidence, ReasonCode: "fixture", StatementSHA256: statement.Digest.SHA256, SignerID: d.Coordinator.ID, SignerKeyID: d.Coordinator.KeyID, RecordedAt: created.Add(time.Second).Format(time.RFC3339Nano)} + var restart *SignedArtifactRefs + if kind == CheckpointRestarted { + next := d + next.SessionNonceHex = strings.Repeat("de", 32) + var err error + next, err = FinalizeCeremonyDefinition(next) + if err != nil { + t.Fatal(err) + } + pair := putCheckpointTestPairV4(t, source, "restart/ceremony", next, next.Coordinator.KeyID, key) + restart = &pair + evidence = checkpointArtifacts(statement, pair.Record, pair.Signature) + record.Evidence, record.NewCeremonyID = evidence, next.CeremonyID + } + rp := putCheckpointTestPairV4(t, source, "governance/record", record, d.Coordinator.KeyID, key) + next := nextCheckpointV4(t, initial, CheckpointTransitionV4{Kind: kind, Record: &rp, Evidence: evidence, RestartDefinition: restart}) + if kind != CheckpointIncidentRecorded { + next.Progress.Terminal = &CheckpointTerminalV4{Kind: governanceKindV4(kind), Record: rp, RestartDefinition: restart} + } + sequence = append(sequence, next) + } + var head SignedArtifactRefs + for n := range sequence { + if n > 0 { + previous := head + sequence[n].PreviousCheckpoint = &previous + } + head = putCheckpointTestPairV4(t, source, fmt.Sprintf("checkpoints/%04d", n), sequence[n], d.Coordinator.KeyID, key) + } + putCheckpointTestFileV4(t, stage, "ceremony.json", db) + putCheckpointTestFileV4(t, stage, "ceremony.sig", ds) + anchor := filepath.Join(t.TempDir(), "coordinator.hex") + if err := os.WriteFile(anchor, []byte(hex.EncodeToString(key.Public().(ed25519.PublicKey))), 0600); err != nil { + t.Fatal(err) + } + trust := TrustPaths{DefinitionPath: filepath.Join(stage, "ceremony.json"), DefinitionSignaturePath: filepath.Join(stage, "ceremony.sig"), CoordinatorPublicKeyPath: anchor} + copyRef := func(ref ArtifactRef) []byte { + t.Helper() + data, err := os.ReadFile(filepath.Join(source, ref.Name)) + if err != nil { + t.Fatal(err) + } + putCheckpointTestFileV4(t, stage, ref.Name, data) + return data + } + var dependencies []ArtifactRef + for current := &head; current != nil; { + raw, sig := copyRef(current.Record), copyRef(current.Signature) + discovery, err := DiscoverSignedCheckpointV4(d, db, ds, raw, sig) + if err != nil { + t.Fatal(err) + } + for _, ref := range discovery.VerificationDependencies { + copyRef(ref) + dependencies = append(dependencies, ref) + } + current = discovery.PreviousCheckpoint + } + if _, err := VerifyStoredCheckpointV4(trust, stage, head); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(stage, sequence[len(sequence)-1].Progress.Phase1.HeadPayload.Name)); !os.IsNotExist(err) { + t.Fatal("sync unexpectedly copied contribution payload") + } + for _, ref := range dependencies { + if err := os.Remove(filepath.Join(stage, ref.Name)); err != nil { + t.Fatal(err) + } + if _, err := VerifyStoredCheckpointV4(trust, stage, head); err == nil { + t.Fatalf("missing dependency accepted: %s", ref.Name) + } + copyRef(ref) + } + }) + } +} + +func TestCheckpointDiscoveryV4DoesNotAuthorizeIllegalTransition(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, initial, Phase1) + c := turn[1] + c.Sequence += 2 // Individually signed, but an illegal sequence jump. + raw, sig, err := SignRecord(c, d.Coordinator.KeyID, adversarialPrivateKey(1)) + if err != nil { + t.Fatal(err) + } + if _, err := DiscoverSignedCheckpointV4(d, db, ds, raw, sig); err != nil { + t.Fatal(err) + } + if err := ValidateCheckpointTransitionV4(initial, c); err == nil { + t.Fatal("discovery must not replace transition verification") + } +} + +func TestCheckpointDiscoveryV4DependencySizeLimits(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + for _, target := range []string{"record", "signature", "statement", "restart-record", "restart-signature"} { + for _, over := range []bool{false, true} { + pair := checkpointSigned("governance/record") + statement := checkpointArtifact("governance/statement.txt", "public") + restart := checkpointSigned("restart/ceremony") + var ref *ArtifactRef + var limit int64 + switch target { + case "record": + ref, limit = &pair.Record, maxSignedRecordBytes + case "signature": + ref, limit = &pair.Signature, 4096 + case "statement": + ref, limit = &statement, 1<<20 + case "restart-record": + ref, limit = &restart.Record, maxSignedRecordBytes + case "restart-signature": + ref, limit = &restart.Signature, 4096 + } + ref.Digest.Size = limit + if over { + ref.Digest.Size++ + } + tx := CheckpointTransitionV4{Kind: CheckpointRestarted, Record: &pair, RestartDefinition: &restart, Evidence: checkpointArtifacts(statement, restart.Record, restart.Signature)} + c := nextCheckpointV4(t, initial, tx) + c.Progress.Terminal = &CheckpointTerminalV4{Kind: GovernanceRestart, Record: pair, RestartDefinition: &restart} + raw, sig, err := SignRecord(c, d.Coordinator.KeyID, adversarialPrivateKey(1)) + if err != nil { + t.Fatal(err) + } + _, err = DiscoverSignedCheckpointV4(d, db, ds, raw, sig) + if (err != nil) != over { + t.Fatalf("%s over=%v: %v", target, over, err) + } + } + } +} diff --git a/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go b/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go new file mode 100644 index 00000000..c485b70a --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go @@ -0,0 +1,77 @@ +package mpcceremony + +import "errors" + +// EnrollmentMetadataV4 verifies identities and proof of possession for the +// complete checkpoint-committed enrollment set, not disclosure file contents +// or completeness against the required ceremony roster. +type EnrollmentMetadataV4 struct { + CeremonyID string `json:"ceremony_id"` + Checkpoint SignedArtifactRefs `json:"checkpoint"` + Enrollments []CommittedEnrollmentMetadataV4 `json:"enrollments"` +} + +type CommittedEnrollmentMetadataV4 struct { + Refs SignedArtifactRefs `json:"refs"` + Enrollment EnrollmentRecord `json:"enrollment"` +} + +func InspectCheckpointEnrollmentsV4(trust TrustPaths, artifactRoot string, head SignedArtifactRefs) (EnrollmentMetadataV4, error) { + _, _, metadata, err := InspectCheckpointGuidanceV4(trust, artifactRoot, head) + return metadata, err +} + +// InspectCheckpointGuidanceV4 authenticates ancestry once and returns two +// distinct results: structural commitments and verified enrollment metadata. +func InspectCheckpointGuidanceV4(trust TrustPaths, artifactRoot string, head SignedArtifactRefs) (CheckpointV4, CheckpointCommitmentsV4, EnrollmentMetadataV4, error) { + c, err := openStoredCheckpointV4(trust, artifactRoot, head) + if err != nil { + return CheckpointV4{}, CheckpointCommitmentsV4{}, EnrollmentMetadataV4{}, err + } + defer func() { _ = c.reader.root.Close() }() + index, err := checkpointCommitmentsV4(c.ancestry) + if err != nil { + return CheckpointV4{}, CheckpointCommitmentsV4{}, EnrollmentMetadataV4{}, err + } + metadata, err := checkpointEnrollmentMetadataV4(c, head) + if err != nil { + return CheckpointV4{}, CheckpointCommitmentsV4{}, EnrollmentMetadataV4{}, err + } + return c.ancestry.head, index, metadata, nil +} + +func checkpointEnrollmentMetadataV4(c *storedCheckpointContextV4, head SignedArtifactRefs) (EnrollmentMetadataV4, error) { + if len(c.ancestry.enrollments) > 128 { + return EnrollmentMetadataV4{}, errors.New("checkpoint enrollment set exceeds protocol capacity") + } + r := EnrollmentMetadataV4{CeremonyID: c.trusted.Definition.CeremonyID, Checkpoint: head, Enrollments: []CommittedEnrollmentMetadataV4{}} + seen := map[string]EnrollmentRecord{} + disclosures := map[SignedArtifactRefs]ArtifactRef{} + for _, tx := range c.ancestry.enrollmentTransitions { + if len(tx.Evidence) != 1 { + return EnrollmentMetadataV4{}, errors.New("invalid committed enrollment disclosure reference") + } + disclosures[*tx.Record] = tx.Evidence[0] + } + for _, pair := range sortedSignedRefsV4(c.ancestry.enrollments) { + raw, sig, err := c.reader.pair(pair) + if err != nil { + return EnrollmentMetadataV4{}, err + } + record, err := VerifyEnrollmentProofOfPossession(c.trusted.Definition, c.definitionBytes, raw, sig) + if err != nil { + return EnrollmentMetadataV4{}, err + } + if record.IndependenceDisclosure != disclosures[pair] { + return EnrollmentMetadataV4{}, errors.New("enrollment disclosure reference differs from committed evidence") + } + if (record.Role == EnrollmentPublicWitness || record.Role == EnrollmentMirrorOperator) && record.RoleIndex > MaxAuditors { + return EnrollmentMetadataV4{}, errors.New("observer assignment exceeds protocol capacity") + } + if err := addCheckpointEnrollmentV4(seen, record); err != nil { + return EnrollmentMetadataV4{}, err + } + r.Enrollments = append(r.Enrollments, CommittedEnrollmentMetadataV4{Refs: pair, Enrollment: record}) + } + return r, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_enrollment_metadata_test.go b/internal/mpcceremony/checkpoint_v4_enrollment_metadata_test.go new file mode 100644 index 00000000..b89f0e90 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_enrollment_metadata_test.go @@ -0,0 +1,78 @@ +package mpcceremony + +import ( + "os" + "path/filepath" + "testing" +) + +func TestCheckpointEnrollmentMetadataExactSetAndHead(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + root := t.TempDir() + disclosure := checkpointArtifact("disclosure.txt", "not retained in metadata fixture") + record, err := NewEnrollmentRecord(d, db, d.Coordinator, EnrollmentCoordinator, 1, disclosure, d.CreatedAt) + if err != nil { + t.Fatal(err) + } + pair := putCheckpointTestPairV4(t, root, "enrollments/coordinator", record, d.Coordinator.KeyID, adversarialPrivateKey(1)) + // A second valid pair at another name is not a committed enrollment. + putCheckpointTestPairV4(t, root, "loose/coordinator", record, d.Coordinator.KeyID, adversarialPrivateKey(1)) + next := nextCheckpointV4(t, initial, CheckpointTransitionV4{Kind: CheckpointEnrollmentRecorded, Record: &pair, Evidence: []ArtifactRef{disclosure}}) + sequence := []CheckpointV4{initial, next} + trust, head := storeCommitmentSequenceV4(t, d, db, ds, root, sequence) + checkpointBytes, err := os.ReadFile(filepath.Join(root, head.Record.Name)) + if err != nil { + t.Fatal(err) + } + checkpointSignature, err := os.ReadFile(filepath.Join(root, head.Signature.Name)) + if err != nil { + t.Fatal(err) + } + discovery, err := DiscoverSignedCheckpointV4(d, db, ds, checkpointBytes, checkpointSignature) + if err != nil || discovery.Enrollment == nil || *discovery.Enrollment != pair || len(discovery.VerificationDependencies) != 0 { + t.Fatal("enrollment guidance dependency conflated with structural dependencies", err) + } + got, err := InspectCheckpointEnrollmentsV4(trust, root, head) + if err != nil { + t.Fatal(err) + } + if got.CeremonyID != d.CeremonyID || got.Checkpoint != head || len(got.Enrollments) != 1 || got.Enrollments[0].Refs != pair || got.Enrollments[0].Enrollment.Identity.ID != d.Coordinator.ID { + t.Fatalf("wrong exact metadata: %+v", got) + } + if _, err := os.Stat(filepath.Join(root, disclosure.Name)); !os.IsNotExist(err) { + t.Fatal("disclosure was unexpectedly read") + } + for _, ref := range []ArtifactRef{pair.Record, pair.Signature} { + path := filepath.Join(root, ref.Name) + bytes, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if _, err := InspectCheckpointEnrollmentsV4(trust, root, head); err == nil { + t.Fatal("missing committed enrollment accepted") + } + putCheckpointTestFileV4(t, root, ref.Name, []byte("substituted")) + if _, err := InspectCheckpointEnrollmentsV4(trust, root, head); err == nil { + t.Fatal("substituted enrollment accepted") + } + putCheckpointTestFileV4(t, root, ref.Name, bytes) + } + previous := *sequence[1].PreviousCheckpoint + old, err := InspectCheckpointEnrollmentsV4(trust, root, previous) + if err != nil { + t.Fatal(err) + } + if len(old.Enrollments) != 0 { + t.Fatal("later enrollment leaked into older head") + } + // Structural ancestry alone does not read enrollment contents. The batch + // must also bind the signed disclosure reference to the committed edge. + wrong := nextCheckpointV4(t, initial, CheckpointTransitionV4{Kind: CheckpointEnrollmentRecorded, Record: &pair, Evidence: []ArtifactRef{checkpointArtifact("different-disclosure.txt", "wrong")}}) + wrongTrust, wrongHead := storeCommitmentSequenceV4(t, d, db, ds, root, []CheckpointV4{initial, wrong}) + if _, err := InspectCheckpointEnrollmentsV4(wrongTrust, root, wrongHead); err == nil { + t.Fatal("uncommitted disclosure reference accepted") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_enrollments.go b/internal/mpcceremony/checkpoint_v4_enrollments.go new file mode 100644 index 00000000..998f2cb4 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_enrollments.go @@ -0,0 +1,73 @@ +package mpcceremony + +import "errors" + +func readCheckpointEnrollmentV4(reader *checkpointReaderV4, d CeremonyDefinition, db []byte, refs SignedArtifactRefs) (EnrollmentRecord, error) { + rb, sb, err := reader.pair(refs) + if err != nil { + return EnrollmentRecord{}, err + } + var record EnrollmentRecord + if err = UnmarshalCanonical(rb, &record); err != nil { + return record, err + } + if err = record.Validate(); err != nil { + return record, err + } + if (record.Role == EnrollmentPublicWitness || record.Role == EnrollmentMirrorOperator) && record.RoleIndex > MaxAuditors { + return record, errors.New("observer assignment exceeds the supported role limit") + } + signer, err := VerifyOperationalRecordBinding(d, db, &record) + if err != nil { + return record, err + } + key, err := identityPublicKey(signer) + if err != nil { + return record, err + } + if err = VerifySignedRecord(rb, sb, &record, signer.KeyID, key); err != nil { + return record, err + } + if _, err = reader.read(record.IndependenceDisclosure, maxEnrollmentDisclosureBytes, false); err != nil { + return record, err + } + return record, nil +} + +func addCheckpointEnrollmentV4(records map[string]EnrollmentRecord, record EnrollmentRecord) error { + for _, previous := range records { + if previous.Identity.ID == record.Identity.ID || previous.Identity.KeyID == record.Identity.KeyID || previous.Identity.PublicKeyFingerprint == record.Identity.PublicKeyFingerprint || (previous.Role == record.Role && previous.RoleIndex == record.RoleIndex) { + return errors.New("enrollment duplicates an already committed identity, key or role assignment") + } + } + if len(records) >= 128 { + return errors.New("enrollment collection exceeds bundle capacity") + } + records[record.Identity.ID] = record + return nil +} + +func loadCheckpointEnrollmentsV4(reader *checkpointReaderV4, d CeremonyDefinition, db []byte, refs []SignedArtifactRefs) (map[string]EnrollmentRecord, error) { + records := map[string]EnrollmentRecord{} + for _, ref := range refs { + record, err := readCheckpointEnrollmentV4(reader, d, db, ref) + if err != nil { + return nil, err + } + if err = addCheckpointEnrollmentV4(records, record); err != nil { + return nil, err + } + } + return records, nil +} + +func verifyNewCheckpointEnrollmentV4(reader *checkpointReaderV4, d CeremonyDefinition, db []byte, tx CheckpointTransitionV4, records map[string]EnrollmentRecord) error { + record, err := readCheckpointEnrollmentV4(reader, d, db, *tx.Record) + if err != nil { + return err + } + if len(tx.Evidence) != 1 || tx.Evidence[0] != record.IndependenceDisclosure { + return errors.New("enrollment evidence differs from its signed disclosure") + } + return addCheckpointEnrollmentV4(records, record) +} diff --git a/internal/mpcceremony/checkpoint_v4_enrollments_test.go b/internal/mpcceremony/checkpoint_v4_enrollments_test.go new file mode 100644 index 00000000..ad891068 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_enrollments_test.go @@ -0,0 +1,128 @@ +package mpcceremony + +import ( + "bytes" + "reflect" + "testing" +) + +func TestCheckpointV4EnrollmentEdgePreservesActiveDelivery(t *testing.T) { + d, start, _, _ := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, start, Phase1) + previous := turn[1] + pair := checkpointSigned("enrollments/participant") + disclosure := checkpointArtifact("enrollments/disclosure.txt", "one operator") + next := nextCheckpointV4(t, previous, CheckpointTransitionV4{Kind: CheckpointEnrollmentRecorded, Record: &pair, Evidence: []ArtifactRef{disclosure}}) + if err := ValidateCheckpointTransitionV4(previous, next); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(previous.Deliveries, next.Deliveries) { + t.Fatal("delivery changed") + } + next.Deliveries = []DeliverySlotV2{} + if err := ValidateCheckpointTransitionV4(previous, next); err == nil { + t.Fatal("enrollment removed active delivery") + } +} + +func TestCheckpointV4EnrollmentDisclosureLimitMatchesBundle(t *testing.T) { + d, _, db, _ := checkpointFixtureV4(t) + for _, size := range []int{maxEnrollmentDisclosureBytes, maxEnrollmentDisclosureBytes + 1} { + root := t.TempDir() + disclosure := putCheckpointTestFileV4(t, root, "disclosure.txt", bytes.Repeat([]byte("x"), size)) + record, err := NewEnrollmentRecord(d, db, d.Coordinator, EnrollmentCoordinator, 1, disclosure, d.CreatedAt) + if err != nil { + t.Fatal(err) + } + refs := putCheckpointTestPairV4(t, root, "enrollment", record, d.Coordinator.KeyID, adversarialPrivateKey(1)) + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + _, err = readCheckpointEnrollmentV4(reader, d, db, refs) + _ = reader.root.Close() + if (err == nil) != (size <= maxEnrollmentDisclosureBytes) { + t.Fatalf("size %d: %v", size, err) + } + } +} + +func TestCheckpointV4ObserverAssignmentBound(t *testing.T) { + d, _, _, _ := checkpointFixtureV4(t) + d.AssurancePolicy.PublicWitnessesPerPhase = 1 + var err error + d, err = FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + db, err := MarshalCanonical(d) + if err != nil { + t.Fatal(err) + } + identity := adversarialIdentity(t, "witness-01", 0xa1) + for _, index := range []uint16{0, MaxAuditors, MaxAuditors + 1} { + root := t.TempDir() + disclosure := putCheckpointTestFileV4(t, root, "disclosure.txt", []byte("fixture observer")) + record, err := NewEnrollmentRecord(d, db, identity, EnrollmentPublicWitness, index, disclosure, d.CreatedAt) + if index == 0 { + if err == nil { + t.Fatal("zero assignment accepted") + } + continue + } + if err != nil { + t.Fatal(err) + } + refs := putCheckpointTestPairV4(t, root, "enrollment", record, identity.KeyID, adversarialPrivateKey(0xa1)) + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + _, err = readCheckpointEnrollmentV4(reader, d, db, refs) + _ = reader.root.Close() + if (err == nil) != (index <= MaxAuditors) { + t.Fatalf("index %d: %v", index, err) + } + } +} + +func TestCheckpointV4DisabledMirrorsRejectEvidence(t *testing.T) { + d, _, db, _ := checkpointFixtureV4(t) + d.AssurancePolicy.MirrorsPerAcceptedHead = 0 + // Disabled controls reject records before attempting to read their bytes. + if _, err := verifyCheckpointMirrorsV4(nil, d, db, nil, nil, []SignedArtifactRefs{checkpointSigned("mirror")}); err == nil { + t.Fatal("disabled mirror record accepted") + } +} + +func TestCheckpointV4EnrollmentAuthenticatesDisclosureAndUniqueness(t *testing.T) { + d, _, db, _ := checkpointFixtureV4(t) + root := t.TempDir() + disclosure := putCheckpointTestFileV4(t, root, "enrollments/disclosure.txt", []byte("single operator test")) + record, err := NewEnrollmentRecord(d, db, d.Coordinator, EnrollmentCoordinator, 1, disclosure, d.CreatedAt) + if err != nil { + t.Fatal(err) + } + refs := putCheckpointTestPairV4(t, root, "enrollments/coordinator", record, d.Coordinator.KeyID, adversarialPrivateKey(1)) + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.root.Close() }() + tx := CheckpointTransitionV4{Kind: CheckpointEnrollmentRecorded, Record: &refs, Evidence: []ArtifactRef{disclosure}} + records := map[string]EnrollmentRecord{} + if err = verifyNewCheckpointEnrollmentV4(reader, d, db, tx, records); err != nil { + t.Fatal(err) + } + if err = verifyNewCheckpointEnrollmentV4(reader, d, db, tx, records); err == nil { + t.Fatal("duplicate identity accepted") + } + tx.Evidence = []ArtifactRef{inventoryTestRef("enrollments/other.txt", []byte("other"))} + if err = verifyNewCheckpointEnrollmentV4(reader, d, db, tx, map[string]EnrollmentRecord{}); err == nil { + t.Fatal("different disclosure accepted") + } + putCheckpointTestFileV4(t, root, disclosure.Name, []byte("changed disclosure")) + if _, err = loadCheckpointEnrollmentsV4(reader, d, db, []SignedArtifactRefs{refs}); err == nil { + t.Fatal("changed historical disclosure accepted") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go new file mode 100644 index 00000000..d56b0f05 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -0,0 +1,767 @@ +package mpcceremony + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "time" + + "golang.org/x/crypto/blake2b" +) + +// checkpointReaderV4 confines all referenced reads to one public staging root. +// Large payloads are hashed as streams; JSON and signatures remain bounded. +type checkpointReaderV4 struct { + root *os.Root + path string + flatCandidate bool // internal V4 release layout only; never caller-defined aliases +} + +func openCheckpointReaderV4(path string) (*checkpointReaderV4, error) { + if path == "" { + return nil, errors.New("public artifact root is required") + } + abs, err := filepath.Abs(path) + if err != nil { + return nil, err + } + info, err := os.Lstat(abs) + if err != nil { + return nil, err + } + if !info.IsDir() || info.Mode()&os.ModeSymlink != 0 { + return nil, errors.New("public artifact root must be a real directory") + } + root, err := os.OpenRoot(abs) + if err != nil { + return nil, err + } + return &checkpointReaderV4{root: root, path: abs}, nil +} + +func (r *checkpointReaderV4) read(ref ArtifactRef, limit int64, capture bool) ([]byte, error) { + if err := ref.Validate(); err != nil { + return nil, err + } + if err := validatePortableStorageName(ref.Name); err != nil { + return nil, err + } + if ref.Digest.Size <= 0 || ref.Digest.Size > limit { + return nil, fmt.Errorf("artifact %s exceeds its permitted size", ref.Name) + } + if capture && limit > maxFinalTranscriptV3Bytes { + return nil, errors.New("large artifacts must be streamed, not retained in memory") + } + name := ref.Name + if r.flatCandidate { + var err error + name, err = releasePhysicalNameV4(name) + if err != nil { + return nil, err + } + } + parts := strings.Split(name, "/") + var before os.FileInfo + for i := range parts { + info, err := r.root.Lstat(filepath.Join(parts[:i+1]...)) + if err != nil { + return nil, err + } + if info.Mode()&os.ModeSymlink != 0 || (i < len(parts)-1 && !info.IsDir()) { + return nil, errors.New("artifact path must not traverse symbolic links or non-directories") + } + before = info + } + if !before.Mode().IsRegular() || before.Size() != ref.Digest.Size { + return nil, fmt.Errorf("artifact %s is not a regular file of the expected size", ref.Name) + } + f, err := r.root.Open(filepath.FromSlash(name)) + if err != nil { + return nil, err + } + defer f.Close() + opened, err := f.Stat() + if err != nil { + return nil, err + } + if !os.SameFile(before, opened) || opened.Size() != before.Size() { + return nil, errors.New("artifact changed while being opened") + } + sha := sha256.New() + blake, _ := blake2b.New256(nil) + writers := []io.Writer{sha, blake} + var buf bytes.Buffer + if capture { + writers = append(writers, &buf) + } + size, err := io.Copy(io.MultiWriter(writers...), io.LimitReader(f, ref.Digest.Size+1)) + if err != nil { + return nil, err + } + after, err := f.Stat() + if err != nil { + return nil, err + } + actual := Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size} + if after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) { + return nil, fmt.Errorf("artifact %s changed while being read", ref.Name) + } + if actual != ref.Digest { + return nil, candidateArtifactDigestMismatch(fmt.Errorf("artifact %s differs from its exact committed bytes", ref.Name)) + } + if capture { + return buf.Bytes(), nil + } + return nil, nil +} + +func (r *checkpointReaderV4) pair(refs SignedArtifactRefs) ([]byte, []byte, error) { + if err := refs.Validate(); err != nil { + return nil, nil, err + } + record, err := r.read(refs.Record, maxSignedRecordBytes, true) + if err != nil { + return nil, nil, err + } + signature, err := r.read(refs.Signature, 4096, true) + if err != nil { + return nil, nil, err + } + return record, signature, nil +} + +type checkpointAncestryV4 struct { + head CheckpointV4 + allocations map[string]CheckpointTransitionV4 + enrollments []SignedArtifactRefs + enrollmentTransitions []CheckpointTransitionV4 + mirrors []SignedArtifactRefs + witnesses []SignedArtifactRefs + audits []SignedArtifactRefs + incidents []CheckpointTransitionV4 + accepted map[ContributionScope]SignedArtifactRefs + acceptedTransitions map[ContributionScope]CheckpointTransitionV4 + checkpoints []SignedArtifactRefs // newest to oldest, including head + finalCandidateCheckpoint *SignedArtifactRefs + count uint64 + turnCommitments map[ContributionScope]*TurnCommitmentV4 +} + +func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, definitionBytes, definitionSignature []byte, refs SignedArtifactRefs) (checkpointAncestryV4, error) { + result := checkpointAncestryV4{allocations: map[string]CheckpointTransitionV4{}, accepted: map[ContributionScope]SignedArtifactRefs{}, acceptedTransitions: map[ContributionScope]CheckpointTransitionV4{}, turnCommitments: map[ContributionScope]*TurnCommitmentV4{}} + var child *CheckpointV4 + for { + if result.count > MaxCheckpointSequenceV4 { + return checkpointAncestryV4{}, errors.New("checkpoint ancestry exceeds protocol limit") + } + record, signature, err := reader.pair(refs) + if err != nil { + return checkpointAncestryV4{}, err + } + current, err := VerifySignedCheckpointV4(d, definitionBytes, definitionSignature, record, signature) + if err != nil { + return checkpointAncestryV4{}, err + } + if child == nil { + result.head = current + } else { + // reader.pair already matched both exact predecessor references. + if err := ValidateCheckpointTransitionV4(current, *child); err != nil { + return checkpointAncestryV4{}, err + } + // Check scoped governance while its exact authenticated predecessor + // is in hand. This also covers callers that signed a checkpoint + // without the normal preparation API, without retaining whole copies + // of every checkpoint's growing inventory in memory. + if isGovernanceTransitionV4(child.Transition.Kind) { + if err := verifyCheckpointGovernanceV4(reader, d, current, child.Transition); err != nil { + return checkpointAncestryV4{}, err + } + } + } + result.count++ + if err := collectTurnCommitmentV4(result.turnCommitments, current, refs); err != nil { + return checkpointAncestryV4{}, err + } + result.checkpoints = append(result.checkpoints, refs) + if current.Transition.Kind == CheckpointIncidentRecorded { + result.incidents = append(result.incidents, current.Transition) + } + if current.Transition.Kind == CheckpointFinalCandidateRecorded { + pair := refs + result.finalCandidateCheckpoint = &pair + } + if current.Transition.Kind == CheckpointAuditRecorded { + result.audits = append(result.audits, *current.Transition.Record) + } + if current.Transition.Kind == CheckpointWitnessRecorded { + result.witnesses = append(result.witnesses, *current.Transition.Record) + } + if current.Transition.Kind == CheckpointMirrorRecorded { + result.mirrors = append(result.mirrors, *current.Transition.Record) + } + if current.Transition.Kind == CheckpointPhase1CandidateAccepted || current.Transition.Kind == CheckpointPhase2CandidateAccepted { + if _, exists := result.acceptedTransitions[*current.Transition.Scope]; exists { + return checkpointAncestryV4{}, errors.New("duplicate accepted transition for the same contribution scope") + } + result.accepted[*current.Transition.Scope] = *current.Transition.Record + result.acceptedTransitions[*current.Transition.Scope] = current.Transition + } + if current.Transition.Kind == CheckpointEnrollmentRecorded { + result.enrollments = append(result.enrollments, *current.Transition.Record) + result.enrollmentTransitions = append(result.enrollmentTransitions, current.Transition) + } + if current.Transition.Kind == CheckpointPhase1CandidateAllocated || current.Transition.Kind == CheckpointPhase2CandidateAllocated { + if _, exists := result.allocations[current.Transition.AttemptID]; exists { + return checkpointAncestryV4{}, errors.New("duplicate candidate allocation attempt") + } + result.allocations[current.Transition.AttemptID] = current.Transition + } + if current.PreviousCheckpoint == nil { + return result, nil + } + refs = *current.PreviousCheckpoint + child = ¤t + } +} + +// VerifyStoredCheckpointV4 authenticates the exact signed checkpoint ancestry +// and legal structural edges. It neither downloads nor hashes every historical +// large payload, replays contribution mathematics, or claims global freshness. +// Governance edges additionally recheck their bounded evidence and exact scope. +// The delivery service selects the current root; callers supply its exact pair. +func VerifyStoredCheckpointV4(trust TrustPaths, artifactRoot string, head SignedArtifactRefs) (CheckpointV4, error) { + c, err := openStoredCheckpointV4(trust, artifactRoot, head) + if err != nil { + return CheckpointV4{}, err + } + defer func() { _ = c.reader.root.Close() }() + return c.ancestry.head, nil +} + +type storedCheckpointContextV4 struct { + reader *checkpointReaderV4 + trusted *TrustedCeremony + definitionBytes []byte + ancestry checkpointAncestryV4 +} + +// The caller owns the returned reader and must close it. Failed construction +// never leaves an open root or returns a partially verified ancestry. +func openStoredCheckpointV4(trust TrustPaths, artifactRoot string, head SignedArtifactRefs) (*storedCheckpointContextV4, error) { + trusted, err := LoadSignedDefinition(trust) + if err != nil { + return nil, err + } + db, err := MarshalCanonical(trusted.Definition) + if err != nil { + return nil, err + } + ds, err := readRegularBounded(trust.DefinitionSignaturePath, 4096) + if err != nil { + return nil, err + } + reader, err := openCheckpointReaderV4(artifactRoot) + if err != nil { + return nil, err + } + ancestry, err := loadCheckpointAncestryV4(reader, trusted.Definition, db, ds, head) + if err != nil { + _ = reader.root.Close() + return nil, err + } + return &storedCheckpointContextV4{reader: reader, trusted: trusted, definitionBytes: db, ancestry: ancestry}, nil +} + +// CheckpointPreparationV4 verifies a proposed protocol update before it may be +// signed. Proposal contains only protocol references; Relay owns delivery +// manifests and object keys. No files are written and no signature is created. +type CheckpointPreparationV4 struct { + Trust TrustPaths + ArtifactRoot string + Proposal CheckpointV4 + Circuit *CompiledCircuit + // RequireCurrentReplayExecutable is set by authoring/signing paths. A + // verifier may authenticate a checkpoint produced by any executable in the + // definition's signed allowlist; it must not require that historical replay + // to have used the verifier's own platform binary. + RequireCurrentReplayExecutable bool + // RejectedCandidateDir is private input only for an explicit rejection. Its + // normalized inventory hashes become state, never these unaccepted files. + RejectedCandidateDir string +} + +func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { + if (options.Proposal.Transition.Kind == CheckpointContributionRejected) != (options.RejectedCandidateDir != "") { + return nil, errors.New("only explicit rejection requires a private candidate directory") + } + trusted, err := loadOperationalCeremony(options.Trust) + if err != nil { + return nil, err + } + d := trusted.Definition + db, err := MarshalCanonical(d) + if err != nil { + return nil, err + } + ds, err := readRegularBounded(options.Trust.DefinitionSignaturePath, 4096) + if err != nil { + return nil, err + } + c := options.Proposal + if err := validateCheckpointDefinitionBindingV4(d, db, ds, c); err != nil { + return nil, err + } + reader, err := openCheckpointReaderV4(options.ArtifactRoot) + if err != nil { + return nil, err + } + defer func() { _ = reader.root.Close() }() + var previous *CheckpointV4 + allocations := map[string]CheckpointTransitionV4{} + enrollments := []SignedArtifactRefs{} + var evidenceAncestry checkpointAncestryV4 + if c.PreviousCheckpoint != nil { + ancestry, err := loadCheckpointAncestryV4(reader, d, db, ds, *c.PreviousCheckpoint) + if err != nil { + return nil, err + } + previous = &ancestry.head + allocations = ancestry.allocations + enrollments = ancestry.enrollments + evidenceAncestry = ancestry + if err := ValidateCheckpointTransitionV4(*previous, c); err != nil { + return nil, err + } + } + // Check every newly accepted byte before issuing any signable result. + if isGovernanceTransitionV4(c.Transition.Kind) { + // A stop must remain possible with missing unrelated payloads or + // incomplete enrollments. Verify only its exact authorizing evidence. + if previous == nil { + return nil, errors.New("governance requires an initialized ceremony") + } + if err := verifyCheckpointGovernanceV4(reader, d, *previous, c.Transition); err != nil { + return nil, err + } + return MarshalCanonical(c) + } + for _, ref := range c.AcceptedArtifacts { + if previous != nil && slices.Contains(previous.AcceptedArtifacts, ref) { + continue + } + limit := MaxArtifactSize + if strings.HasSuffix(ref.Name, ".sig") { + limit = 4096 + } else if strings.HasSuffix(ref.Name, ".json") { + limit = maxSignedRecordBytes + } + if c.Transition.Kind == CheckpointFinalReleaseRecorded { + limit = finalReleaseArtifactLimitV4(ref) + } + if _, err := reader.read(ref, limit, false); err != nil { + return nil, err + } + } + verifiedEnrollments, err := loadCheckpointEnrollmentsV4(reader, d, db, enrollments) + if err != nil { + return nil, err + } + if c.Transition.Kind == CheckpointEnrollmentRecorded { + if err := verifyNewCheckpointEnrollmentV4(reader, d, db, c.Transition, verifiedEnrollments); err != nil { + return nil, err + } + return MarshalCanonical(c) + } + if c.Transition.Kind == CheckpointReleaseReviewRecorded { + if previous == nil || previous.Progress.FinalCandidate == nil || previous.Progress.ReleaseReview != nil || c.Transition.Record == nil || + c.Transition.Record.Record.Name != OperationalEvidenceBundleFile || c.Transition.Record.Signature.Name != OperationalEvidenceSignatureFile { + return nil, errors.New("release review requires the canonical signed operational bundle after one frozen candidate") + } + bundleBytes, signatureBytes, err := reader.pair(*c.Transition.Record) + if err != nil { + return nil, err + } + var bundle OperationalEvidenceBundle + if err := VerifySignedRecord(bundleBytes, signatureBytes, &bundle, d.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { + return nil, err + } + assembledAt, err := time.Parse(time.RFC3339Nano, bundle.AssembledAt) + if err != nil { + return nil, errors.New("operational bundle has an invalid assembly time") + } + derived, err := deriveOperationalBundleV4(reader, trusted, db, evidenceAncestry, assembledAt) + if err != nil { + return nil, err + } + canonical, err := MarshalCanonical(derived) + if err != nil { + return nil, err + } + if !bytes.Equal(canonical, bundleBytes) { + return nil, errors.New("signed operational bundle differs from the exact final-candidate checkpoint") + } + return MarshalCanonical(c) + } + if c.Transition.Kind == CheckpointAuditRecorded || c.Transition.Kind == CheckpointFinalReleaseRecorded { + refs := append([]SignedArtifactRefs{}, evidenceAncestry.audits...) + if c.Transition.Kind == CheckpointAuditRecorded { + refs = append(refs, *c.Transition.Record) + } + if _, err := verifyCheckpointAuditsV4(reader, d, previous.Progress, verifiedEnrollments, refs, c.Transition.Kind == CheckpointFinalReleaseRecorded); err != nil { + return nil, err + } + if c.Transition.Kind == CheckpointAuditRecorded { + return MarshalCanonical(c) + } + } + if c.Transition.Kind == CheckpointMirrorRecorded || c.Transition.Kind == CheckpointPhase1Closed || c.Transition.Kind == CheckpointPhase2Closed { + mirrorRefs := append([]SignedArtifactRefs{}, evidenceAncestry.mirrors...) + if c.Transition.Kind == CheckpointMirrorRecorded { + mirrorRefs = append(mirrorRefs, *c.Transition.Record) + } + mirrors, err := verifyCheckpointMirrorsV4(reader, d, db, evidenceAncestry.accepted, verifiedEnrollments, mirrorRefs) + if err != nil { + return nil, err + } + if c.Transition.Kind == CheckpointMirrorRecorded { + return MarshalCanonical(c) + } + phase := Phase1 + if c.Transition.Kind == CheckpointPhase2Closed { + phase = Phase2 + } + for scope := range evidenceAncestry.accepted { + if scope.Phase == phase && len(mirrors[scope]) < int(d.AssurancePolicy.MirrorsPerAcceptedHead) { + return nil, errors.New("each accepted head requires its signed mirror minimum before closure") + } + } + } + if c.Transition.Kind == CheckpointPhase1CandidateAllocated || c.Transition.Kind == CheckpointPhase2CandidateAllocated { + if _, ok := verifiedEnrollments[c.Transition.Scope.ParticipantID]; !ok { + return nil, errors.New("participant enrollment must be committed before candidate allocation") + } + } + if c.Transition.Kind == CheckpointWitnessRecorded || c.Transition.Kind == CheckpointPhase1Sealed || c.Transition.Kind == CheckpointFinalCandidateRecorded { + witnessRefs := append([]SignedArtifactRefs{}, evidenceAncestry.witnesses...) + if c.Transition.Kind == CheckpointWitnessRecorded { + witnessRefs = append(witnessRefs, *c.Transition.Record) + } + witnessCounts, err := verifyCheckpointWitnessesV4(reader, d, previous.Progress, verifiedEnrollments, witnessRefs) + if err != nil { + return nil, err + } + if c.Transition.Kind == CheckpointWitnessRecorded { + return MarshalCanonical(c) + } + phase := Phase1 + if c.Transition.Kind == CheckpointFinalCandidateRecorded { + phase = Phase2 + } + if witnessCounts[phase] < int(d.AssurancePolicy.PublicWitnessesPerPhase) { + return nil, errors.New("signed witness minimum is not satisfied for this phase") + } + } + if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, allocations); err != nil { + return nil, err + } + return MarshalCanonical(c) +} + +func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous *CheckpointV4, allocations map[string]CheckpointTransitionV4) error { + c := options.Proposal + d := trusted.Definition + switch c.Transition.Kind { + case CheckpointInitial: + chain, refs, err := VerifyAcceptedPhase1Chain(options.Trust, options.Circuit, PhaseTranscriptPaths{RootDir: reader.path, ChainPath: filepath.Join(reader.path, c.Progress.Phase1.Chain.Record.Name), ChainSignaturePath: filepath.Join(reader.path, c.Progress.Phase1.Chain.Signature.Name)}) + if err != nil { + return err + } + return verifyV4ChainProjection(chain, refs, c.Progress.Phase1) + case CheckpointPhase1CandidateAllocated, CheckpointPhase2CandidateAllocated: + return verifyCandidateAllocationV4(d, *previous, c.Transition) + case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + return verifyAcceptedCandidateV4(options, trusted, reader, *previous, allocations) + case CheckpointContributionRejected: + return verifyRejectedInventoryV4(options.RejectedCandidateDir, *c.Transition.Contribution) + case CheckpointDeliveryRetired, CheckpointDeliveryReallocated: + return nil // No protocol claim or accepted artifact is added. + case CheckpointPhase1Closed, CheckpointPhase2Closed, CheckpointPhase1BeaconRecorded, CheckpointPhase2BeaconRecorded, CheckpointPhase1Sealed, CheckpointPhase2Initialized: + return verifyCheckpointLifecycleV4(options, trusted, reader, *previous) + case CheckpointFinalCandidateRecorded: + return verifyFinalCandidateV4(options, trusted, reader, *previous) + case CheckpointFinalReleaseRecorded: + _, _, err := verifyFinalReleasePackageV4(options.Trust, reader.path, c) + return err + default: + return errors.New("real-artifact authoring for this v4 transition is not implemented yet") + } +} + +func verifyRejectedInventoryV4(dir string, inventory CandidateInventory) error { + if err := inventory.Validate(); err != nil { + return err + } + reader, err := openCheckpointReaderV4(dir) + if err != nil { + return err + } + defer func() { _ = reader.root.Close() }() + entries, err := reader.root.Open(".") + if err != nil { + return err + } + defer entries.Close() + names, err := entries.Readdirnames(len(inventory.Files) + 1) + if err != nil && !errors.Is(err, io.EOF) { + return err + } + if len(names) != len(inventory.Files) { + return errors.New("rejected directory does not contain the exact complete candidate inventory") + } + for _, ref := range inventory.Files { + if !slices.Contains(names, ref.Name) { + return errors.New("rejected directory has missing or extra candidate files") + } + limit := int64(maxSignedRecordBytes) + if ref.Name == "contribution.bin" { + limit = MaxArtifactSize + } else if strings.HasSuffix(ref.Name, ".sig") { + limit = 4096 + } + if _, err := reader.read(ref, limit, false); err != nil { + return err + } + } + return nil +} + +func verifyAcceptedCandidateV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous CheckpointV4, allocations map[string]CheckpointTransitionV4) error { + c := options.Proposal + scope := *c.Transition.Scope + before, after := previous.Progress.Phase1, c.Progress.Phase1 + if scope.Phase == Phase2 { + before = *previous.Progress.Phase2 + after = *c.Progress.Phase2 + } + paths := PhaseTranscriptPaths{RootDir: reader.path, ChainPath: filepath.Join(reader.path, after.Chain.Record.Name), ChainSignaturePath: filepath.Join(reader.path, after.Chain.Signature.Name)} + var chain Chain + var refs SignedArtifactRefs + var err error + if scope.Phase == Phase1 { + chain, refs, err = VerifyAcceptedPhase1Chain(options.Trust, options.Circuit, paths) + } else { + seal := previous.Progress.Phase1Seal + chain, refs, err = VerifyAcceptedPhase2Chain(options.Trust, options.Circuit, reader.path, filepath.Join(reader.path, seal.Record.Name), filepath.Join(reader.path, seal.Signature.Name), paths) + } + if err != nil { + return err + } + if err := verifyV4ChainProjection(chain, refs, after); err != nil { + return err + } + oldBytes, oldSig, err := reader.pair(before.Chain) + if err != nil { + return err + } + var old Chain + if err := VerifySignedRecord(oldBytes, oldSig, &old, trusted.Definition.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { + return err + } + if err := old.ValidateAgainstDefinition(trusted.Definition); err != nil { + return err + } + if err := verifyV4ChainProjection(old, before.Chain, before); err != nil { + return err + } + if chain.PhaseID != old.PhaseID || chain.Genesis != old.Genesis || len(chain.Records) != len(old.Records)+1 || !reflect.DeepEqual(chain.Records[:len(old.Records)], old.Records) { + return errors.New("accepted chain does not extend the exact previous chain") + } + last := chain.Records[len(chain.Records)-1] + if err := verifyCandidateChainInventoryV4(last, scope, *c.Transition.Contribution, c.Transition.Evidence); err != nil { + return err + } + if last.ParticipantID != scope.ParticipantID { + return errors.New("accepted chain names another participant") + } + allocation, ok := allocations[c.Transition.AttemptID] + if !ok || allocation.Scope == nil || *allocation.Scope != scope { + return errors.New("candidate has no matching authenticated allocation") + } + return verifyCandidateChronologyV4(reader, allocation, c.Transition, chain) +} + +func verifyCandidateChronologyV4(reader *checkpointReaderV4, allocation, tx CheckpointTransitionV4, chain Chain) error { + read := func(ref ArtifactRef, out any) error { + b, err := reader.read(ref, maxSignedRecordBytes, true) + if err != nil { + return err + } + return UnmarshalCanonical(b, out) + } + last := chain.Records[len(chain.Records)-1] + var attestation ContributionAttestation + var erasure ErasureAttestation + if err := read(last.Attestation, &attestation); err != nil { + return err + } + if err := read(last.Erasure, &erasure); err != nil { + return err + } + timestamps := []string{allocation.AllocatedAt, attestation.ContributedAt, erasure.DestroyedAt, last.AcceptedAt} + var before time.Time + for i, value := range timestamps { + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return err + } + // Existing cleanup validation permits the same recorded timestamp as + // contribution; allocation and acceptance must be strictly ordered. + if i > 0 && ((i == 2 && parsed.Before(before)) || (i != 2 && !parsed.After(before))) { + return errors.New("candidate allocation, contribution, cleanup and acceptance timestamps are not ordered") + } + before = parsed + } + return nil +} + +func verifyCandidateAllocationV4(d CeremonyDefinition, previous CheckpointV4, tx CheckpointTransitionV4) error { + if tx.Scope == nil { + return errors.New("candidate allocation lacks its scope") + } + if err := tx.Scope.ValidateAssignment(d); err != nil { + return err + } + if err := previous.Progress.currentTurn(*tx.Scope); err != nil { + return err + } + participant, ok := d.ParticipantByID(tx.Scope.ParticipantID) + if !ok || participant.Identity.Ed25519PublicKeyHex == "" { + return errors.New("candidate allocation lacks the assigned participant signing key") + } + return nil +} + +// Bind the delivery result to the exact bytes covered by chain replay, not +// merely another valid set of artifacts present in the same transcript. +func verifyCandidateChainInventoryV4(last ChainRecord, scope ContributionScope, inventory CandidateInventory, evidence []ArtifactRef) error { + if err := inventory.Validate(); err != nil { + return err + } + if inventory.Scope != scope { + return errors.New("candidate inventory names another contribution scope") + } + base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) + expected := []ArtifactRef{last.Attestation, last.AttestationSignature, last.OutputPayload, last.Erasure, last.ErasureSignature} + for i, ref := range expected { + mapped := inventory.Files[i] + mapped.Name = base + mapped.Name + if mapped != ref { + return errors.New("candidate inventory differs from the replayed chain artifacts") + } + } + for _, ref := range evidence { + if ref.Name == base+"verification.json" { + if ref != last.Verification { + return errors.New("candidate verification differs from the replayed chain artifact") + } + return nil + } + } + return errors.New("candidate verification artifact is missing") +} + +func verifyV4ChainProjection(chain Chain, refs SignedArtifactRefs, state CheckpointPhaseState) error { + head, err := chain.HeadPayload() + if err != nil { + return err + } + id, err := chain.HeadRecordID() + if err != nil { + return err + } + if chain.Phase != state.Phase || len(chain.Records) != int(state.AcceptedCount) || refs != state.Chain || head != state.HeadPayload || id != state.HeadRecordID { + return errors.New("checkpoint projection differs from the authenticated chain") + } + return nil +} + +func verifyOutboundHandoffV4(reader *checkpointReaderV4, d CeremonyDefinition, previous CheckpointV4, scope ContributionScope, refs SignedArtifactRefs) (TransferHandoff, error) { + var handoff TransferHandoff + if err := scope.ValidateAssignment(d); err != nil { + return handoff, err + } + if err := previous.Progress.currentTurn(scope); err != nil { + return handoff, err + } + record, signature, err := reader.pair(refs) + if err != nil { + return handoff, err + } + key, err := identityPublicKey(d.Coordinator) + if err != nil { + return handoff, err + } + if err := VerifySignedRecord(record, signature, &handoff, d.Coordinator.KeyID, key); err != nil { + return handoff, err + } + if err := verifyTransferSource(d, handoff.Source); err != nil { + return handoff, err + } + participant, _ := d.ParticipantByID(scope.ParticipantID) + head := previous.Progress.Phase1.HeadPayload + if scope.Phase == Phase2 { + head = previous.Progress.Phase2.HeadPayload + } + if handoff.CeremonyID != d.CeremonyID || handoff.Phase != scope.Phase || handoff.Index != scope.Index || handoff.PredecessorHeadID != scope.ParentHeadID || handoff.SenderID != d.Coordinator.ID || handoff.SenderKeyID != d.Coordinator.KeyID || handoff.RecipientID != scope.ParticipantID || handoff.RecipientKeyID != participant.Identity.KeyID || !slices.Equal(handoff.Files, []ArtifactRef{head}) { + return TransferHandoff{}, errors.New("outbound handoff does not bind the exact scheduled participant and accepted input") + } + return handoff, nil +} + +func verifyOutboundReceiptV4(reader *checkpointReaderV4, d CeremonyDefinition, previous CheckpointV4, t CheckpointTransitionV4, outbound map[string]SignedArtifactRefs) error { + participant, ok := d.ParticipantByID(t.Scope.ParticipantID) + if !ok { + return errors.New("receipt participant is not in the signed roster") + } + key, err := identityPublicKey(participant.Identity) + if err != nil { + return err + } + record, signature, err := reader.pair(*t.Record) + if err != nil { + return err + } + var receipt TransferReceipt + if err := VerifySignedRecord(record, signature, &receipt, participant.Identity.KeyID, key); err != nil { + return err + } + refs, ok := outbound[receipt.HandoffSHA256] + if !ok { + return errors.New("receipt does not name a handoff committed in this checkpoint ancestry") + } + handoff, err := verifyOutboundHandoffV4(reader, d, previous, *t.Scope, refs) + if err != nil { + return err + } + handoffBytes, _, err := reader.pair(refs) + if err != nil { + return err + } + // Confirm a second read did not silently change the parsed signed record. + var again TransferHandoff + if err := UnmarshalCanonical(handoffBytes, &again); err != nil { + return err + } + if !reflect.DeepEqual(handoff, again) { + return errors.New("handoff changed during receipt verification") + } + return VerifyTransferReceipt(handoffBytes, handoff, receipt) +} diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go new file mode 100644 index 00000000..e7d55f46 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -0,0 +1,393 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "fmt" + "os" + "os/exec" + "path/filepath" + "reflect" + "runtime" + "strings" + "testing" +) + +func TestCheckpointV4RealContributionTurn(t *testing.T) { + if testing.Short() { + t.Skip("real signed contribution checkpoint round trip") + } + if runtime.GOOS != "linux" { + t.Skip("executable identity and contributor environment require Linux; run in Docker") + } + _, source, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("resolve source") + } + repo := filepath.Clean(filepath.Join(filepath.Dir(source), "..", "..")) + helper := filepath.Join(t.TempDir(), "workflow") + build := exec.Command("go", "build", "-o", helper, "./internal/mpcceremony/testdata/workflowhelper") + build.Dir = repo + if output, err := build.CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, output) + } + for _, scenario := range []struct{ name, mirrorMode, extra, rejection string }{ + {name: "observers-disabled", mirrorMode: "0"}, + {name: "observers-enabled", mirrorMode: "1"}, + {name: "audits-enabled", mirrorMode: "1", extra: "MPC_WORKFLOW_V4_AUDITS=1"}, + {name: "missing-witness", mirrorMode: "1", extra: "MPC_WORKFLOW_SKIP_WITNESS=1", rejection: "signed witness minimum"}, + } { + t.Run(scenario.name, func(t *testing.T) { + outputRoot := filepath.Join(t.TempDir(), "ceremony-run") + run := exec.Command(helper, outputRoot) + run.Dir = repo + for _, entry := range os.Environ() { + if strings.HasPrefix(entry, "MPC_WORKFLOW_") || strings.HasPrefix(entry, "MPC_CEREMONY_TEST_") || strings.HasPrefix(entry, "PROOF_TOOL_TEST_") { + continue + } + run.Env = append(run.Env, entry) + } + run.Env = append(run.Env, "MPC_WORKFLOW_CHECKPOINT_V4=1", "PROOF_TOOL_TEST_ZERO_ASSURANCE=1", "MPC_WORKFLOW_V4_MIRROR="+scenario.mirrorMode) + if scenario.extra != "" { + run.Env = append(run.Env, scenario.extra) + } + output, err := run.CombinedOutput() + if scenario.rejection != "" { + if err == nil || !strings.Contains(string(output), scenario.rejection) { + t.Fatalf("expected %s: %v\n%s", scenario.rejection, err, output) + } + return + } + if err != nil { + t.Fatalf("real checkpoint turn: %v\n%s", err, output) + } + if !strings.Contains(string(output), "V4 real phase1 turn passed") || !strings.Contains(string(output), "V4 phase2 and final candidate passed") { + t.Fatalf("missing completion: %s", output) + } + if scenario.extra == "MPC_WORKFLOW_V4_AUDITS=1" && !strings.Contains(string(output), "V4 audits passed: two real replays") { + t.Fatalf("missing audited completion: %s", output) + } + if !strings.Contains(string(output), "V4 operational bundle passed: deterministic checkpoint-only assembly") { + t.Fatalf("missing bundle completion: %s", output) + } + if !strings.Contains(string(output), "V4 terminal branch passed: authenticated abort") { + t.Fatalf("missing terminal completion: %s", output) + } + if !strings.Contains(string(output), "V4 final review passed: no contribution replay input") { + t.Fatalf("missing final review completion: %s", output) + } + if !strings.Contains(string(output), "V4 signed package passed: exact-only source") { + t.Fatal("real fixture did not complete V4 release package signing and verification") + } + if !strings.Contains(string(output), "V4 final release checkpoint passed: private package") { + t.Fatal("real fixture did not record and verify the complete private release package") + } + if scenario.name == "observers-disabled" { + testV4CoherentInvalidPublicProof(t, filepath.Join(outputRoot, "ceremony")) + } + }) + } +} + +func TestCheckpointV4LifecycleCanonicalPaths(t *testing.T) { + for _, phase := range []Phase{Phase1, Phase2} { + for _, directory := range []string{"closure", "beacon"} { + base := string(phase) + "/" + directory + "/record" + refs := SignedArtifactRefs{Record: inventoryTestRef(base+".json", []byte("record")), Signature: inventoryTestRef(base+".sig", []byte("signature"))} + if err := requireLifecycleRecordPathV4(refs, phase, directory); err != nil { + t.Fatal(err) + } + for _, field := range []string{"record", "signature"} { + bad := refs + if field == "record" { + bad.Record.Name = "other/record.json" + } else { + bad.Signature.Name = "other/record.sig" + } + if err := requireLifecycleRecordPathV4(bad, phase, directory); err == nil { + t.Fatal("accepted misplaced lifecycle record") + } + } + } + } +} + +func TestCheckpointV4Phase2CloseBoundary(t *testing.T) { + first := CloseRecord{BeaconRound: 42} + beacon := BeaconRecord{PublishedAt: "2023-08-23T15:11:30Z"} + second := CloseRecord{BeaconRound: 43, ClosedAt: "2023-08-23T15:11:31Z"} + if err := validatePhase2CloseBoundaryV4(first, beacon, second); err != nil { + t.Fatal(err) + } + for name, change := range map[string]func(*CloseRecord){ + "same round": func(c *CloseRecord) { c.BeaconRound = 42 }, + "older round": func(c *CloseRecord) { c.BeaconRound = 41 }, + "before publication": func(c *CloseRecord) { c.ClosedAt = "2023-08-23T15:11:29Z" }, + "same time": func(c *CloseRecord) { c.ClosedAt = beacon.PublishedAt }, + } { + t.Run(name, func(t *testing.T) { + bad := second + change(&bad) + if err := validatePhase2CloseBoundaryV4(first, beacon, bad); err == nil { + t.Fatal("invalid phase2 boundary accepted") + } + }) + } +} + +func TestCheckpointV4RequiredEnrollmentsFitExistingBundle(t *testing.T) { + // Coordinator, release signer, full roster, auditors, witnesses and mirrors. + // External security-audit signoffs are later decision evidence, not enrollment. + maximumRequired := 2 + MaxParticipants + 3*MaxAuditors + if maximumRequired > 128 { + t.Fatalf("maximum required enrollments %d exceeds bundle capacity", maximumRequired) + } +} + +func putCheckpointTestFileV4(t *testing.T, root, name string, data []byte) ArtifactRef { + t.Helper() + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, data, 0600); err != nil { + t.Fatal(err) + } + return inventoryTestRef(name, data) +} + +func TestCheckpointV4CandidateInventoryMatchesReplayedChain(t *testing.T) { + _, inventory := candidateInventoryFixture(t) + scope := inventory.Scope + base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) + mapped := append([]ArtifactRef(nil), inventory.Files...) + for i := range mapped { + mapped[i].Name = base + mapped[i].Name + } + verification := inventoryTestRef(base+"verification.json", []byte("verified")) + last := ChainRecord{Attestation: mapped[0], AttestationSignature: mapped[1], OutputPayload: mapped[2], Erasure: mapped[3], ErasureSignature: mapped[4], Verification: verification} + evidence := append(mapped, verification) + if err := verifyCandidateChainInventoryV4(last, scope, inventory, evidence); err != nil { + t.Fatal(err) + } + for i, ref := range inventory.Files[:5] { + t.Run(ref.Name, func(t *testing.T) { + changed := inventory + changed.Files = append([]ArtifactRef(nil), inventory.Files...) + changed.Files[i].Digest = NewDigest([]byte("different bytes")) + if err := verifyCandidateChainInventoryV4(last, scope, changed, evidence); err == nil { + t.Fatal("accepted an inventory different from the replayed chain") + } + }) + } + wrong := append([]ArtifactRef(nil), evidence...) + wrong[len(wrong)-1].Digest = NewDigest([]byte("another verification")) + if err := verifyCandidateChainInventoryV4(last, scope, inventory, wrong); err == nil { + t.Fatal("accepted another verification record") + } + if err := verifyCandidateChainInventoryV4(last, scope, inventory, nil); err == nil { + t.Fatal("accepted missing verification record") + } + last.Attestation.Name = "another/attestation.json" + if err := verifyCandidateChainInventoryV4(last, scope, inventory, evidence); err == nil { + t.Fatal("accepted different logical path with identical bytes") + } +} + +func putCheckpointTestPairV4(t *testing.T, root, name string, record any, keyID string, key ed25519.PrivateKey) SignedArtifactRefs { + t.Helper() + rb, sb, err := SignRecord(record, keyID, key) + if err != nil { + t.Fatal(err) + } + return SignedArtifactRefs{Record: putCheckpointTestFileV4(t, root, name+".json", rb), Signature: putCheckpointTestFileV4(t, root, name+".sig", sb)} +} + +func TestCheckpointV4ReaderStreamsAndConfinesFiles(t *testing.T) { + root := t.TempDir() + data := bytes.Repeat([]byte{0x31}, 20<<20) + ref := putCheckpointTestFileV4(t, root, "payload.bin", data) + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.root.Close() }() + if got, err := reader.read(ref, MaxArtifactSize, false); err != nil || got != nil { + t.Fatalf("streaming: %d %v", len(got), err) + } + if _, err := reader.read(ref, MaxArtifactSize, true); err == nil { + t.Fatal("large payload retained in memory") + } + if _, err := reader.read(ref, maxSignedRecordBytes, false); err == nil { + t.Fatal("size bound ignored") + } + wrong := ref + wrong.Digest = NewDigest(bytes.Repeat([]byte{0x32}, len(data))) + if _, err := reader.read(wrong, MaxArtifactSize, false); err == nil { + t.Fatal("wrong digest accepted") + } + if err := os.Symlink(filepath.Join(root, "payload.bin"), filepath.Join(root, "linked.bin")); err != nil { + t.Fatal(err) + } + linked := ref + linked.Name = "linked.bin" + if _, err := reader.read(linked, MaxArtifactSize, false); err == nil { + t.Fatal("symlink leaf accepted") + } + outside := t.TempDir() + small := putCheckpointTestFileV4(t, outside, "small.json", []byte("outside")) + if err := os.Symlink(outside, filepath.Join(root, "redirect")); err != nil { + t.Fatal(err) + } + small.Name = "redirect/small.json" + if _, err := reader.read(small, 100, true); err == nil { + t.Fatal("symlink ancestor accepted") + } + for _, name := range []string{"../outside", "/absolute", "UPPER.json"} { + small.Name = name + if _, err := reader.read(small, 100, true); err == nil { + t.Fatalf("unsafe name accepted %s", name) + } + } + if err := os.Truncate(filepath.Join(root, "payload.bin"), 1); err != nil { + t.Fatal(err) + } + if _, err := reader.read(ref, MaxArtifactSize, false); err == nil { + t.Fatal("truncated payload accepted") + } +} + +func TestStoredCheckpointV4VerifiesAncestryWithoutClaimingPayloadPresence(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + sequence := checkpointTurnV4(t, d, initial, Phase1) + root := t.TempDir() + key := adversarialPrivateKey(1) + putCheckpointTestFileV4(t, root, "ceremony.json", db) + putCheckpointTestFileV4(t, root, "ceremony.sig", ds) + anchor := filepath.Join(t.TempDir(), "coordinator.hex") + if err := os.WriteFile(anchor, []byte(hex.EncodeToString(key.Public().(ed25519.PublicKey))), 0600); err != nil { + t.Fatal(err) + } + trust := TrustPaths{DefinitionPath: filepath.Join(root, "ceremony.json"), DefinitionSignaturePath: filepath.Join(root, "ceremony.sig"), CoordinatorPublicKeyPath: anchor} + var previous SignedArtifactRefs + for i := range sequence { + if i > 0 { + refs := previous + sequence[i].PreviousCheckpoint = &refs + } + previous = putCheckpointTestPairV4(t, root, fmt.Sprintf("checkpoints/%04d", i), sequence[i], d.Coordinator.KeyID, key) + } + got, err := VerifyStoredCheckpointV4(trust, root, previous) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(got, sequence[len(sequence)-1]) { + t.Fatal("wrong head") + } + // No large payloads exist in this fixture. The API authenticates state, + // not completeness of downloaded files or contribution mathematics. + if _, err := os.Stat(filepath.Join(root, got.Progress.Phase1.HeadPayload.Name)); !os.IsNotExist(err) { + t.Fatal("fixture unexpectedly has payload") + } + if err := os.WriteFile(filepath.Join(root, "checkpoints/0001.sig"), []byte("changed signature"), 0600); err != nil { + t.Fatal(err) + } + if _, err := VerifyStoredCheckpointV4(trust, root, previous); err == nil { + t.Fatal("changed predecessor signature accepted") + } +} + +func TestReceiptV4FindsCommittedHandoffAfterRetirement(t *testing.T) { + d, initial, _, _ := checkpointFixtureV4(t) + scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: Phase1, Index: 1, ParticipantID: d.Phase1Policy.Participants[0], ParentHeadID: initial.Progress.Phase1.HeadRecordID} + participant, _ := d.ParticipantByID(scope.ParticipantID) + handoff := TransferHandoff{Schema: TransferHandoffSchema, CeremonyID: d.CeremonyID, Phase: Phase1, Index: 1, PredecessorHeadID: scope.ParentHeadID, + Source: TransferSourceBinding{SourceCommit: d.Software.SourceCommit, ToolBinary: d.Software.ToolBinary, R1CS: d.Circuit.R1CS}, + Files: []ArtifactRef{initial.Progress.Phase1.HeadPayload}, SenderID: d.Coordinator.ID, SenderKeyID: d.Coordinator.KeyID, + RecipientID: participant.Identity.ID, RecipientKeyID: participant.Identity.KeyID, CreatedAt: "2026-09-16T00:00:00Z", ExpiresAt: "2026-09-16T01:00:00Z"} + root := t.TempDir() + h := putCheckpointTestPairV4(t, root, "handoffs/outbound", handoff, d.Coordinator.KeyID, adversarialPrivateKey(1)) + receipt := TransferReceipt{Schema: TransferReceiptSchema, Kind: ReceiptReceiver, HandoffSHA256: h.Record.Digest.SHA256, + CeremonyID: d.CeremonyID, Phase: Phase1, Index: 1, PredecessorHeadID: scope.ParentHeadID, Source: handoff.Source, Files: handoff.Files, + SenderID: handoff.SenderID, SenderKeyID: handoff.SenderKeyID, RecipientID: handoff.RecipientID, RecipientKeyID: handoff.RecipientKeyID, + SignerID: handoff.RecipientID, SignerKeyID: handoff.RecipientKeyID, ReceivedAt: "2026-09-16T00:01:00Z"} + r := putCheckpointTestPairV4(t, root, "receipts/outbound", receipt, participant.Identity.KeyID, adversarialPrivateKey(0x11)) + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.root.Close() }() + previous := initial + previous.Transition = CheckpointTransitionV4{Kind: CheckpointDeliveryRetired, Evidence: []ArtifactRef{}} + tx := CheckpointTransitionV4{Kind: CheckpointPhase1ReceiptAccepted, Scope: &scope, Record: &r} + known := map[string]SignedArtifactRefs{h.Record.Digest.SHA256: h} + if err := verifyOutboundReceiptV4(reader, d, previous, tx, known); err != nil { + t.Fatal(err) + } + newer := handoff + newer.CreatedAt = "2026-09-16T00:00:30Z" + newerPair := putCheckpointTestPairV4(t, root, "handoffs/newer-outbound", newer, d.Coordinator.KeyID, adversarialPrivateKey(1)) + known[newerPair.Record.Digest.SHA256] = newerPair + // Publishing B does not change this receipt's signed acknowledgement of A. + if err := verifyOutboundReceiptV4(reader, d, previous, tx, known); err != nil { + t.Fatal(err) + } + if err := verifyOutboundReceiptV4(reader, d, previous, tx, map[string]SignedArtifactRefs{newerPair.Record.Digest.SHA256: newerPair}); err == nil { + t.Fatal("newest packet substituted for acknowledged older packet") + } + if err := verifyOutboundReceiptV4(reader, d, previous, tx, map[string]SignedArtifactRefs{}); err == nil { + t.Fatal("uncommitted handoff accepted") + } + receipt.ReceivedAt = "2026-09-16T02:00:00Z" + r = putCheckpointTestPairV4(t, root, "receipts/late", receipt, participant.Identity.KeyID, adversarialPrivateKey(0x11)) + tx.Record = &r + if err := verifyOutboundReceiptV4(reader, d, previous, tx, known); err == nil { + t.Fatal("receipt outside validity window accepted") + } +} + +func TestRejectedInventoryV4ChecksExactPrivateBytes(t *testing.T) { + _, inventory := candidateInventoryFixture(t) + root := t.TempDir() + for i, ref := range inventory.Files { + inventory.Files[i] = putCheckpointTestFileV4(t, root, ref.Name, []byte("actual candidate bytes for "+ref.Name)) + } + if err := verifyRejectedInventoryV4(root, inventory); err != nil { + t.Fatal(err) + } + putCheckpointTestFileV4(t, root, "secret-extra.txt", []byte("synthetic extra")) + if err := verifyRejectedInventoryV4(root, inventory); err == nil { + t.Fatal("extra file accepted") + } + if err := os.Remove(filepath.Join(root, "secret-extra.txt")); err != nil { + t.Fatal(err) + } + putCheckpointTestFileV4(t, root, "attestation.sig", []byte("changed")) + if err := verifyRejectedInventoryV4(root, inventory); err == nil { + t.Fatal("wrong rejected inventory accepted") + } +} + +func TestRejectedCandidateInventoryV4HashesOpaqueFixedFiles(t *testing.T) { + root := t.TempDir() + scope := ContributionScope{CeremonyID: NewDigest([]byte("ceremony")).SHA256, Phase: Phase1, Index: 1, ParticipantID: "participant-1", ParentHeadID: NewDigest([]byte("head")).SHA256} + for name, data := range map[string][]byte{ + "attestation.json": []byte("not JSON"), "attestation.sig": []byte("not a signature"), "contribution.bin": []byte("unverified contribution"), "erasure.json": []byte("not cleanup"), "erasure.sig": []byte("not a signature"), + } { + putCheckpointTestFileV4(t, root, name, data) + } + inventory, err := rejectedCandidateInventoryV4(root, scope) + if err != nil { + t.Fatalf("opaque rejected candidate rejected: %v", err) + } + if len(inventory.Files) != 5 || inventory.Files[0].Digest != NewDigest([]byte("not JSON")) { + t.Fatalf("wrong opaque inventory: %+v", inventory) + } + if err := os.WriteFile(filepath.Join(root, "extra"), []byte("extra"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := rejectedCandidateInventoryV4(root, scope); err == nil { + t.Fatal("rejected candidate inventory accepted an extra file") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_final.go b/internal/mpcceremony/checkpoint_v4_final.go new file mode 100644 index 00000000..c4f1d77b --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_final.go @@ -0,0 +1,66 @@ +package mpcceremony + +import ( + "errors" + "path/filepath" + "reflect" + "slices" + "strings" +) + +func verifyFinalCandidateV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous CheckpointV4) error { + t := options.Proposal.Transition + if t.Record.Record.Name != "final/candidate/"+CandidateMetadataFile || t.Record.Signature.Name != "final/candidate/"+CandidateSignatureFile { + return errors.New("final candidate must use its canonical closed directory") + } + if t.ReplayVerification == nil { + return errors.New("final candidate requires the coordinator replay claim") + } + if options.RequireCurrentReplayExecutable { + running, err := RunningSoftwareBindingForMode(trusted.Definition.Software.ProofToolVersion, trusted.Definition.Mode) + if err != nil { + return err + } + if t.ReplayVerification.ToolBinary != running.ToolBinary { + return errors.New("final candidate replay claim must identify the executable performing this replay") + } + } + paths, err := finalReplayPathsV4(options.Trust, trusted.Definition.Coordinator.Ed25519PublicKeyHex, reader.path, previous.Progress) + if err != nil { + return err + } + _, refs, err := VerifyFinalCandidateCheckpoint(paths, options.Circuit, filepath.Join(reader.path, "final/candidate")) + if err != nil { + return err + } + for i := range refs { + refs[i].Name = "final/candidate/" + refs[i].Name + } + want := append([]ArtifactRef{t.Record.Record, t.Record.Signature}, t.Evidence...) + slices.SortFunc(want, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + if !reflect.DeepEqual(refs, want) { + return errors.New("final candidate checkpoint differs from the complete replayed file inventory") + } + return nil +} + +// finalReplayPathsV4 derives every replay input from the authenticated previous +// checkpoint. Callers cannot substitute an unrelated, independently valid chain +// or beacon when claiming verification of this particular ceremony state. +func finalReplayPathsV4(trust TrustPaths, coordinatorKey, root string, p CheckpointProgressV4) (ReplayPaths, error) { + if p.Phase1Closure == nil || p.Phase1Beacon == nil || p.Phase1Seal == nil || p.Phase2 == nil || p.Phase2Closure == nil || p.Phase2Beacon == nil { + return ReplayPaths{}, errors.New("final replay requires both completed phases") + } + path := func(ref ArtifactRef) string { return filepath.Join(root, filepath.FromSlash(ref.Name)) } + return ReplayPaths{ + TranscriptRoot: root, CoordinatorPublicKeyHex: coordinatorKey, + DefinitionPath: trust.DefinitionPath, DefinitionSignaturePath: trust.DefinitionSignaturePath, + Phase1ChainPath: path(p.Phase1.Chain.Record), Phase1ChainSignaturePath: path(p.Phase1.Chain.Signature), + Phase1ClosePath: path(p.Phase1Closure.Record), Phase1CloseSignaturePath: path(p.Phase1Closure.Signature), + Phase1BeaconPath: path(p.Phase1Beacon.Record), Phase1BeaconSignaturePath: path(p.Phase1Beacon.Signature), + Phase1SealPath: path(p.Phase1Seal.Record), Phase1SealSignaturePath: path(p.Phase1Seal.Signature), + Phase2ChainPath: path(p.Phase2.Chain.Record), Phase2ChainSignaturePath: path(p.Phase2.Chain.Signature), + Phase2ClosePath: path(p.Phase2Closure.Record), Phase2CloseSignaturePath: path(p.Phase2Closure.Signature), + Phase2BeaconPath: path(p.Phase2Beacon.Record), Phase2BeaconSignaturePath: path(p.Phase2Beacon.Signature), + }, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_final_test.go b/internal/mpcceremony/checkpoint_v4_final_test.go new file mode 100644 index 00000000..fcd6b58b --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_final_test.go @@ -0,0 +1,51 @@ +package mpcceremony + +import ( + "path/filepath" + "testing" +) + +func TestFinalReplayPathsV4UsesExactCheckpoint(t *testing.T) { + pair := func(name string) *SignedArtifactRefs { + return &SignedArtifactRefs{Record: ArtifactRef{Name: name + ".json"}, Signature: ArtifactRef{Name: name + ".sig"}} + } + p := CheckpointProgressV4{ + Phase1Closure: pair("phase1/closure/record"), Phase1Beacon: pair("phase1/beacon/record"), Phase1Seal: pair("phase1/seal/record"), + Phase2Closure: pair("phase2/closure/record"), Phase2Beacon: pair("phase2/beacon/record"), + } + p.Phase1.Chain = *pair("phase1/chain-0002") + phase2 := p.Phase1 + phase2.Chain = *pair("phase2/chain-0003") + p.Phase2 = &phase2 + root := t.TempDir() + trust := TrustPaths{DefinitionPath: "trusted-definition", DefinitionSignaturePath: "trusted-signature"} + got, err := finalReplayPathsV4(trust, "trusted-key", root, p) + if err != nil { + t.Fatal(err) + } + if got.Phase1ChainPath != filepath.Join(root, "phase1/chain-0002.json") || got.Phase2ChainSignaturePath != filepath.Join(root, "phase2/chain-0003.sig") || got.DefinitionPath != trust.DefinitionPath || got.CoordinatorPublicKeyHex != "trusted-key" { + t.Fatalf("replay inputs changed: %+v", got) + } + for _, missing := range []string{"phase1 closure", "phase1 beacon", "phase1 seal", "phase2", "phase2 closure", "phase2 beacon"} { + t.Run(missing, func(t *testing.T) { + incomplete := p + switch missing { + case "phase1 closure": + incomplete.Phase1Closure = nil + case "phase1 beacon": + incomplete.Phase1Beacon = nil + case "phase1 seal": + incomplete.Phase1Seal = nil + case "phase2": + incomplete.Phase2 = nil + case "phase2 closure": + incomplete.Phase2Closure = nil + case "phase2 beacon": + incomplete.Phase2Beacon = nil + } + if _, err := finalReplayPathsV4(trust, "trusted-key", root, incomplete); err == nil { + t.Fatal("accepted incomplete final replay inputs") + } + }) + } +} diff --git a/internal/mpcceremony/checkpoint_v4_governance.go b/internal/mpcceremony/checkpoint_v4_governance.go new file mode 100644 index 00000000..928cdd8a --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_governance.go @@ -0,0 +1,140 @@ +package mpcceremony + +import ( + "errors" + "reflect" + "slices" + "time" + "unicode/utf8" +) + +func governanceKindV4(kind CheckpointTransitionKind) GovernanceKind { + switch kind { + case CheckpointIncidentRecorded: + return GovernanceIncident + case CheckpointAborted: + return GovernanceAbort + case CheckpointRestarted: + return GovernanceRestart + default: + return "" + } +} + +func isGovernanceTransitionV4(kind CheckpointTransitionKind) bool { + return governanceKindV4(kind) != "" +} + +// The signed checkpoint is the authorization against its exact predecessor. +// The legacy record is a reviewed factual statement; its time is not freshness. +func verifyCheckpointGovernanceV4(reader *checkpointReaderV4, d CeremonyDefinition, previous CheckpointV4, t CheckpointTransitionV4) error { + for _, ref := range previous.AcceptedArtifacts { + if ref.Digest == t.Record.Record.Digest { + return errors.New("governance record is already committed") + } + } + record, err := verifyGovernanceRecordV4(reader, d, t) + if err != nil { + return err + } + state := previous.Progress.Phase1 + if previous.Progress.Phase2 != nil { + state = *previous.Progress.Phase2 + } + index := state.AcceptedCount + if index == 0 { + index = 1 + } // Legacy one-based phase position, not a contribution count. + if record.Phase != state.Phase || record.Index != index || record.HeadID != state.HeadRecordID { + return errors.New("governance does not name the exact current phase and head") + } + return nil +} + +func verifyGovernanceRecordV4(reader *checkpointReaderV4, d CeremonyDefinition, t CheckpointTransitionV4) (GovernanceRecord, error) { + var record GovernanceRecord + if !isGovernanceTransitionV4(t.Kind) { + return record, errors.New("not a V4 governance edge") + } + if err := t.Validate(); err != nil { + return record, err + } + raw, sig, err := reader.pair(*t.Record) + if err != nil { + return record, err + } + key, err := identityPublicKey(d.Coordinator) + if err != nil { + return record, err + } + if err = VerifySignedRecord(raw, sig, &record, d.Coordinator.KeyID, key); err != nil { + return record, err + } + if record.Kind != governanceKindV4(t.Kind) || record.CeremonyID != d.CeremonyID || record.SignerID != d.Coordinator.ID || record.SignerKeyID != d.Coordinator.KeyID { + return GovernanceRecord{}, errors.New("V4 governance requires this ceremony's coordinator and exact action") + } + if !slices.Equal(record.Evidence, t.Evidence) { + return GovernanceRecord{}, errors.New("governance evidence differs from the checkpoint") + } + created, _ := time.Parse(time.RFC3339Nano, d.CreatedAt) + at, _ := time.Parse(time.RFC3339Nano, record.RecordedAt) + if at.Before(created) { + return GovernanceRecord{}, errors.New("governance statement predates the definition") + } + statements := 0 + statementDigests := 0 + for _, ref := range t.Evidence { + if ref.Digest.SHA256 == record.StatementSHA256 { + statementDigests++ + } + if t.RestartDefinition != nil && (ref == t.RestartDefinition.Record || ref == t.RestartDefinition.Signature) { + continue + } + statements++ + if ref.Digest.SHA256 != record.StatementSHA256 { + return GovernanceRecord{}, errors.New("public statement digest does not match governance") + } + content, err := reader.read(ref, 1<<20, true) + if err != nil { + return GovernanceRecord{}, err + } + if !utf8.Valid(content) { + return GovernanceRecord{}, errors.New("public governance statement must be UTF-8 text") + } + } + if statements != 1 || statementDigests != 1 { + return GovernanceRecord{}, errors.New("governance requires exactly one public statement") + } + if t.RestartDefinition != nil { + var next CeremonyDefinition + db, ds, err := reader.pair(*t.RestartDefinition) + if err != nil { + return GovernanceRecord{}, err + } + if err = UnmarshalCanonical(db, &next); err != nil { + return GovernanceRecord{}, err + } + if next.Schema != DefinitionSchemaV4 { + return GovernanceRecord{}, errors.New("V4 restart requires a new V4 definition") + } + newKey, err := identityPublicKey(next.Coordinator) + if err != nil { + return GovernanceRecord{}, err + } + var verified CeremonyDefinition + if err = VerifySignedRecord(db, ds, &verified, next.Coordinator.KeyID, newKey); err != nil { + return GovernanceRecord{}, err + } + if !reflect.DeepEqual(next, verified) { + return GovernanceRecord{}, errors.New("restart definition differs from authenticated bytes") + } + if err = ValidateRestartRecord(d, next, record); err != nil { + return GovernanceRecord{}, err + } + newTime, _ := time.Parse(time.RFC3339Nano, next.CreatedAt) + if newTime.After(at) { + return GovernanceRecord{}, errors.New("restart statement predates the new definition") + } + } + return record, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_governance_test.go b/internal/mpcceremony/checkpoint_v4_governance_test.go new file mode 100644 index 00000000..552d4b86 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_governance_test.go @@ -0,0 +1,191 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "reflect" + "strings" + "testing" + "time" +) + +func TestCheckpointV4TerminationPreservesActiveDeliveries(t *testing.T) { + d, genesis, _, _ := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, genesis, Phase1) + for _, start := range []CheckpointV4{genesis, turn[1], turn[2]} { + for _, kind := range []CheckpointTransitionKind{CheckpointIncidentRecorded, CheckpointAborted, CheckpointRestarted} { + record := checkpointSigned("governance/record") + tx := CheckpointTransitionV4{Kind: kind, Record: &record, Evidence: checkpointArtifacts(checkpointArtifact("governance/statement.txt", "public"))} + if kind == CheckpointRestarted { + fresh := checkpointSigned("restart/ceremony") + tx.RestartDefinition = &fresh + tx.Evidence = appendCheckpointArtifacts(tx.Evidence, fresh.Record, fresh.Signature) + } + next := nextCheckpointV4(t, start, tx) + if kind != CheckpointIncidentRecorded { + next.Progress.Terminal = &CheckpointTerminalV4{Kind: governanceKindV4(kind), Record: record, RestartDefinition: tx.RestartDefinition} + } + if err := ValidateCheckpointTransitionV4(start, next); err != nil { + t.Fatalf("%s at %d: %v", kind, start.Sequence, err) + } + if !reflect.DeepEqual(start.Deliveries, next.Deliveries) { + t.Fatal("history changed") + } + bad := cloneCheckpointV4(t, next) + bad.Transition.NextAttemptID = strings.Repeat("ab", 16) + if err := ValidateCheckpointTransitionV4(start, bad); err == nil { + t.Fatal("governance reallocated a delivery") + } + if next.Progress.Terminal == nil { + continue + } + repeated := nextCheckpointV4(t, next, tx) + repeated.AcceptedArtifacts = append([]ArtifactRef{}, next.AcceptedArtifacts...) + if err := repeated.Validate(); err != nil { + t.Fatal(err) + } + if err := ValidateCheckpointTransitionV4(next, repeated); err == nil || !strings.Contains(err.Error(), "no transition may follow ceremony termination") { + t.Fatalf("structurally valid child did not reach terminal gate: %v", err) + } + for _, later := range []CheckpointTransitionKind{CheckpointIncidentRecorded, CheckpointAborted, CheckpointRestarted, CheckpointEnrollmentRecorded, CheckpointPhase1Closed, CheckpointPhase1CandidateAllocated, CheckpointFinalReleaseRecorded} { + child := nextCheckpointV4(t, next, tx) + child.Transition.Kind = later + child.Progress.Terminal = nil + if err := ValidateCheckpointTransitionV4(next, child); err == nil { + t.Fatalf("%s followed termination", later) + } + } + } + } +} + +func TestCheckpointV4GovernanceSemanticBinding(t *testing.T) { + d, previous, _, _ := checkpointFixtureV4(t) + root := t.TempDir() + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.root.Close() }() + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, 32)) + statement := putCheckpointTestFileV4(t, root, "governance/statement.txt", []byte("Public test statement; no private logs.\n")) + created, _ := time.Parse(time.RFC3339Nano, d.CreatedAt) + r := GovernanceRecord{Schema: GovernanceRecordSchema, Kind: GovernanceAbort, CeremonyID: d.CeremonyID, Phase: Phase1, Index: 1, HeadID: previous.Progress.Phase1.HeadRecordID, Evidence: []ArtifactRef{statement}, ReasonCode: "test-stop", StatementSHA256: statement.Digest.SHA256, SignerID: d.Coordinator.ID, SignerKeyID: d.Coordinator.KeyID, RecordedAt: created.Add(time.Second).Format(time.RFC3339Nano)} + makeTx := func(r GovernanceRecord, signer ed25519.PrivateKey) CheckpointTransitionV4 { + pair := putCheckpointTestPairV4(t, root, "governance/record", r, r.SignerKeyID, signer) + return CheckpointTransitionV4{Kind: CheckpointAborted, Record: &pair, Evidence: []ArtifactRef{statement}} + } + tx := makeTx(r, key) + if err := verifyCheckpointGovernanceV4(reader, d, previous, tx); err != nil { + t.Fatal(err) + } + for name, mutate := range map[string]func(*GovernanceRecord){ + "wrong head": func(r *GovernanceRecord) { r.HeadID = NewDigest([]byte("stale")).SHA256 }, + "wrong phase": func(r *GovernanceRecord) { r.Phase = Phase2 }, + "wrong index": func(r *GovernanceRecord) { r.Index = 2 }, + "wrong identity": func(r *GovernanceRecord) { r.SignerID = d.Roster[0].Identity.ID }, + "wrong statement": func(r *GovernanceRecord) { r.StatementSHA256 = NewDigest([]byte("other")).SHA256 }, + "predates ceremony": func(r *GovernanceRecord) { r.RecordedAt = created.Add(-time.Second).Format(time.RFC3339Nano) }, + "wrong action": func(r *GovernanceRecord) { r.Kind = GovernanceIncident }, + } { + t.Run(name, func(t *testing.T) { + bad := r + mutate(&bad) + if err := verifyCheckpointGovernanceV4(reader, d, previous, makeTx(bad, key)); err == nil { + t.Fatal("accepted bad governance") + } + }) + } + if err := verifyCheckpointGovernanceV4(reader, d, previous, makeTx(r, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)))); err == nil { + t.Fatal("accepted another signing key") + } + tx = makeTx(r, key) + duplicate := previous + duplicate.AcceptedArtifacts = appendCheckpointArtifacts(previous.AcceptedArtifacts, tx.Record.Record) + if err := verifyCheckpointGovernanceV4(reader, d, duplicate, tx); err == nil { + t.Fatal("accepted duplicate governance record") + } + // Phase 2 genesis has the same legacy one-based convention, but another head. + phase2 := previous + phase2.Progress.Phase2 = &CheckpointPhaseState{Phase: Phase2, HeadRecordID: NewDigest([]byte("phase2-genesis")).SHA256} + r.Phase = Phase2 + r.HeadID = phase2.Progress.Phase2.HeadRecordID + if err := verifyCheckpointGovernanceV4(reader, d, phase2, makeTx(r, key)); err != nil { + t.Fatal(err) + } +} + +func TestCheckpointV4GovernanceCanReuseExactPublicStatement(t *testing.T) { + _, before, _, _ := checkpointFixtureV4(t) + statement := checkpointArtifact("governance/public.txt", "reviewed") + incident := checkpointSigned("governance/incident") + after := nextCheckpointV4(t, before, CheckpointTransitionV4{Kind: CheckpointIncidentRecorded, Record: &incident, Evidence: []ArtifactRef{statement}}) + if err := ValidateCheckpointTransitionV4(before, after); err != nil { + t.Fatal(err) + } + stop := checkpointSigned("governance/abort") + tx := CheckpointTransitionV4{Kind: CheckpointAborted, Record: &stop, Evidence: []ArtifactRef{statement}} + terminal := nextCheckpointV4(t, after, tx) + terminal.AcceptedArtifacts = appendCheckpointArtifacts(append([]ArtifactRef{}, after.AcceptedArtifacts...), stop.Record, stop.Signature) + terminal.Progress.Terminal = &CheckpointTerminalV4{Kind: GovernanceAbort, Record: stop} + if err := ValidateCheckpointTransitionV4(after, terminal); err != nil { + t.Fatal(err) + } + bad := cloneCheckpointV4(t, terminal) + bad.AcceptedArtifacts = appendCheckpointArtifacts(bad.AcceptedArtifacts, checkpointArtifact("unexpected.txt", "not authorized")) + if err := ValidateCheckpointTransitionV4(after, bad); err == nil { + t.Fatal("unrelated new artifact accepted") + } +} + +func TestCheckpointV4RestartAuthenticatesExactNewDefinition(t *testing.T) { + d, previous, _, _ := checkpointFixtureV4(t) + root := t.TempDir() + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.root.Close() }() + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, 32)) + statement := putCheckpointTestFileV4(t, root, "restart/statement.txt", []byte("Public restart fixture.\n")) + created, _ := time.Parse(time.RFC3339Nano, d.CreatedAt) + next := d + next.SessionNonceHex = strings.Repeat("de", 32) + next, err = FinalizeCeremonyDefinition(next) + if err != nil { + t.Fatal(err) + } + makeTx := func(next CeremonyDefinition, signer ed25519.PrivateKey) CheckpointTransitionV4 { + pair := putCheckpointTestPairV4(t, root, "restart/ceremony", next, next.Coordinator.KeyID, signer) + evidence := checkpointArtifacts(statement, pair.Record, pair.Signature) + r := GovernanceRecord{Schema: GovernanceRecordSchema, Kind: GovernanceRestart, CeremonyID: d.CeremonyID, Phase: Phase1, Index: 1, HeadID: previous.Progress.Phase1.HeadRecordID, Evidence: evidence, ReasonCode: "test-restart", StatementSHA256: statement.Digest.SHA256, SignerID: d.Coordinator.ID, SignerKeyID: d.Coordinator.KeyID, NewCeremonyID: next.CeremonyID, RecordedAt: created.Add(time.Second).Format(time.RFC3339Nano)} + rp := putCheckpointTestPairV4(t, root, "restart/record", r, d.Coordinator.KeyID, key) + return CheckpointTransitionV4{Kind: CheckpointRestarted, Record: &rp, Evidence: evidence, RestartDefinition: &pair} + } + tx := makeTx(next, key) + if err := verifyCheckpointGovernanceV4(reader, d, previous, tx); err != nil { + t.Fatal(err) + } + if err := verifyCheckpointGovernanceV4(reader, d, previous, makeTx(next, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{3}, 32)))); err == nil { + t.Fatal("accepted wrong new-definition signature") + } + legacy := next + legacy.Schema = DefinitionSchemaV3 + legacy.ReleaseVerification = "" + legacy, err = FinalizeCeremonyDefinition(legacy) + if err != nil { + t.Fatal(err) + } + if err := verifyCheckpointGovernanceV4(reader, d, previous, makeTx(legacy, key)); err == nil { + t.Fatal("accepted legacy restart target") + } + future := next + future.CreatedAt = created.Add(2 * time.Second).Format(time.RFC3339Nano) + future, err = FinalizeCeremonyDefinition(future) + if err != nil { + t.Fatal(err) + } + if err := verifyCheckpointGovernanceV4(reader, d, previous, makeTx(future, key)); err == nil { + t.Fatal("restart predates new definition") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_initialize.go b/internal/mpcceremony/checkpoint_v4_initialize.go new file mode 100644 index 00000000..1e3bf41a --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_initialize.go @@ -0,0 +1,71 @@ +package mpcceremony + +import ( + "errors" + "path/filepath" +) + +// InitialCheckpointV4Options identifies the already initialized, signed +// ceremony files from which the first storage-first checkpoint is derived. +// Callers cannot supply a checkpoint proposal or alter its initial projection. +type InitialCheckpointV4Options struct { + Trust TrustPaths + Circuit *CompiledCircuit + ArtifactRoot string +} + +// InitialCheckpointV4 is a fully checked, unsigned initial checkpoint. The +// caller signs Canonical with the authenticated coordinator key and publishes +// that exact pair through the delivery service. +type InitialCheckpointV4 struct { + Checkpoint CheckpointV4 + Canonical []byte +} + +// PrepareInitialCheckpointV4 derives sequence zero from the authenticated +// definition and the fully replayed Phase 1 genesis chain. This keeps protocol +// JSON construction inside proof-tool rather than a transport controller. +func PrepareInitialCheckpointV4(options InitialCheckpointV4Options) (InitialCheckpointV4, error) { + trusted, err := LoadSignedDefinition(options.Trust) + if err != nil { + return InitialCheckpointV4{}, err + } + d := trusted.Definition + if d.Schema != DefinitionSchemaV4 || d.ReleaseVerification != CoordinatorReplayReleaseV1 { + return InitialCheckpointV4{}, errors.New("initial checkpoint requires the explicit trusted-coordinator definition v4") + } + chainPath := filepath.Join(options.ArtifactRoot, "phase1", "chain-0000.json") + chainSignaturePath := filepath.Join(options.ArtifactRoot, "phase1", "chain-0000.sig") + chain, chainRefs, err := VerifyAcceptedPhase1Chain(options.Trust, options.Circuit, PhaseTranscriptPaths{ + RootDir: options.ArtifactRoot, ChainPath: chainPath, ChainSignaturePath: chainSignaturePath, + }) + if err != nil { + return InitialCheckpointV4{}, err + } + headID, err := chain.HeadRecordID() + if err != nil { + return InitialCheckpointV4{}, err + } + headPayload, err := chain.HeadPayload() + if err != nil { + return InitialCheckpointV4{}, err + } + checkpoint := CheckpointV4{ + Schema: CheckpointSchemaV4, Workflow: StorageFirstWorkflowV2, + CeremonyID: d.CeremonyID, Definition: trusted.DefinitionRefs, + AssurancePolicy: d.AssurancePolicy, ReleaseVerification: d.ReleaseVerification, + Transition: CheckpointTransitionV4{Kind: CheckpointInitial, Evidence: []ArtifactRef{}}, + Progress: CheckpointProgressV4{Phase1: CheckpointPhaseState{ + Phase: Phase1, HeadRecordID: headID, HeadPayload: headPayload, Chain: chainRefs, + }}, + AcceptedArtifacts: appendUniqueSortedArtifactsV4(nil, trusted.DefinitionRefs.Record, trusted.DefinitionRefs.Signature, d.Circuit.R1CS, chainRefs.Record, chainRefs.Signature, headPayload), + Deliveries: []DeliverySlotV2{}, + } + canonical, err := PrepareCheckpointV4(CheckpointPreparationV4{ + Trust: options.Trust, ArtifactRoot: options.ArtifactRoot, Proposal: checkpoint, Circuit: options.Circuit, + }) + if err != nil { + return InitialCheckpointV4{}, err + } + return InitialCheckpointV4{Checkpoint: checkpoint, Canonical: canonical}, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_lifecycle.go b/internal/mpcceremony/checkpoint_v4_lifecycle.go new file mode 100644 index 00000000..24347852 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_lifecycle.go @@ -0,0 +1,160 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path/filepath" + "time" +) + +// Lifecycle preparation consumes the exact refs in the authenticated previous +// checkpoint. It never discovers an alternate chain/closure from loose files. +func verifyCheckpointLifecycleV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous CheckpointV4) error { + c := options.Proposal + path := func(ref ArtifactRef) string { return filepath.Join(reader.path, ref.Name) } + readSigned := func(refs SignedArtifactRefs, out any) error { + record, sig, err := reader.pair(refs) + if err != nil { + return err + } + return VerifySignedRecord(record, sig, out, trusted.Definition.Coordinator.KeyID, trusted.CoordinatorPublicKey) + } + switch c.Transition.Kind { + case CheckpointPhase1Closed, CheckpointPhase2Closed: + state := previous.Progress.Phase1 + if c.Transition.Kind == CheckpointPhase2Closed { + state = *previous.Progress.Phase2 + } + if state.Chain.Record.Name != fmt.Sprintf("%s/chain-%04d.json", state.Phase, state.AcceptedCount) || state.Chain.Signature.Name != fmt.Sprintf("%s/chain-%04d.sig", state.Phase, state.AcceptedCount) { + return errors.New("closure requires the canonical accepted chain paths") + } + if err := requireLifecycleRecordPathV4(*c.Transition.Record, state.Phase, "closure"); err != nil { + return err + } + var chain Chain + if err := readSigned(state.Chain, &chain); err != nil { + return err + } + if err := verifyV4ChainProjection(chain, state.Chain, state); err != nil { + return err + } + var closure CloseRecord + if err := readSigned(*c.Transition.Record, &closure); err != nil { + return err + } + if c.Transition.Kind == CheckpointPhase2Closed { + var priorClose CloseRecord + var priorBeacon BeaconRecord + if err := readSigned(*previous.Progress.Phase1Closure, &priorClose); err != nil { + return err + } + if err := readSigned(*previous.Progress.Phase1Beacon, &priorBeacon); err != nil { + return err + } + if err := ValidateBeacon(trusted.Definition, priorClose, priorBeacon); err != nil { + return err + } + if err := validatePhase2CloseBoundaryV4(priorClose, priorBeacon, closure); err != nil { + return err + } + } + // Full contribution replay happened when accepting each chain; this binds + // closure to that exact signed chain and enforces its signed policy. + return ValidateClose(trusted.Definition, chain, closure) + case CheckpointPhase1BeaconRecorded, CheckpointPhase2BeaconRecorded: + closureRefs := previous.Progress.Phase1Closure + phase := Phase1 + if c.Transition.Kind == CheckpointPhase2BeaconRecorded { + closureRefs = previous.Progress.Phase2Closure + phase = Phase2 + } + if err := requireLifecycleRecordPathV4(*closureRefs, phase, "closure"); err != nil { + return err + } + if err := requireLifecycleRecordPathV4(*c.Transition.Record, phase, "beacon"); err != nil { + return err + } + var closure CloseRecord + if err := readSigned(*closureRefs, &closure); err != nil { + return err + } + var beacon BeaconRecord + if err := readSigned(*c.Transition.Record, &beacon); err != nil { + return err + } + if beacon.RawResponse != c.Transition.Evidence[0] { + return errors.New("beacon checkpoint evidence differs from the signed raw response") + } + if phase == Phase2 { + var prior BeaconRecord + if err := readSigned(*previous.Progress.Phase1Beacon, &prior); err != nil { + return err + } + if beacon.ChallengeSHA256 == prior.ChallengeSHA256 || beacon.Round == prior.Round { + return errors.New("phase2 must use a distinct beacon round and challenge") + } + } + return VerifyBeaconRecordFiles(trusted, reader.path, closure, beacon) + case CheckpointPhase1Sealed: + p := previous.Progress + seal := c.Transition.Record + verified, err := VerifyPhase1SealFiles(VerifyPhase1SealFilesOptions{ + Trust: options.Trust, Circuit: options.Circuit, TranscriptRoot: reader.path, + Phase1ChainPath: path(p.Phase1.Chain.Record), Phase1ChainSignaturePath: path(p.Phase1.Chain.Signature), + Phase1ClosePath: path(p.Phase1Closure.Record), Phase1CloseSignaturePath: path(p.Phase1Closure.Signature), + Phase1BeaconPath: path(p.Phase1Beacon.Record), Phase1BeaconSignaturePath: path(p.Phase1Beacon.Signature), + Phase1SealPath: path(seal.Record), Phase1SealSignaturePath: path(seal.Signature), + }) + if err != nil { + return err + } + if verified.Commons != c.Transition.Evidence[0] { + return errors.New("seal checkpoint payload differs from replayed commons") + } + return nil + case CheckpointPhase2Initialized: + seal := previous.Progress.Phase1Seal + chain := c.Transition.Record + verified, err := VerifyPhase2GenesisFiles(VerifyPhase2GenesisFilesOptions{ + Trust: options.Trust, Circuit: options.Circuit, TranscriptRoot: reader.path, + Phase1SealPath: path(seal.Record), Phase1SealSignaturePath: path(seal.Signature), + Phase2ChainPath: path(chain.Record), Phase2ChainSignaturePath: path(chain.Signature), + }) + if err != nil { + return err + } + if verified.Genesis != c.Transition.Evidence[0] { + return errors.New("phase2 checkpoint payload differs from replayed genesis") + } + return verifyV4ChainProjection(verified.Chain, verified.ChainRefs, *c.Progress.Phase2) + } + return errors.New("unsupported lifecycle verification") +} + +func validatePhase2CloseBoundaryV4(first CloseRecord, beacon BeaconRecord, second CloseRecord) error { + if second.BeaconRound <= first.BeaconRound { + return errors.New("phase2 must use a later beacon round than phase1") + } + closedAt, err := time.Parse(time.RFC3339Nano, second.ClosedAt) + if err != nil { + return err + } + publishedAt, err := time.Parse(time.RFC3339Nano, beacon.PublishedAt) + if err != nil { + return err + } + if !closedAt.After(publishedAt) { + return errors.New("phase2 closure must follow phase1 beacon publication") + } + return nil +} + +// Existing replay derives these logical names. Reject misplaced records when +// authoring, rather than accepting a state the next operation cannot consume. +func requireLifecycleRecordPathV4(refs SignedArtifactRefs, phase Phase, directory string) error { + base := string(phase) + "/" + directory + "/record" + if refs.Record.Name != base+".json" || refs.Signature.Name != base+".sig" { + return errors.New("lifecycle record must use its canonical transcript path") + } + return nil +} diff --git a/internal/mpcceremony/checkpoint_v4_mirrors.go b/internal/mpcceremony/checkpoint_v4_mirrors.go new file mode 100644 index 00000000..07a5f914 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_mirrors.go @@ -0,0 +1,91 @@ +package mpcceremony + +import ( + "errors" + "slices" + "time" +) + +// Receipt counts are reconstructed from authenticated evidence; one signature +// never satisfies another head or a second mirror identity. +func verifyCheckpointMirrorsV4(reader *checkpointReaderV4, d CeremonyDefinition, db []byte, accepted map[ContributionScope]SignedArtifactRefs, enrollments map[string]EnrollmentRecord, refs []SignedArtifactRefs) (map[ContributionScope]map[string]bool, error) { + result := map[ContributionScope]map[string]bool{} + if d.AssurancePolicy.MirrorsPerAcceptedHead == 0 && len(refs) > 0 { + return nil, errors.New("mirror evidence is disabled by signed policy") + } + for _, pair := range refs { + rb, sb, err := reader.pair(pair) + if err != nil { + return nil, err + } + var receipt ImmutableMirrorReceipt + if err = UnmarshalCanonical(rb, &receipt); err != nil { + return nil, err + } + signer, err := VerifyOperationalRecordBinding(d, db, &receipt) + if err != nil { + return nil, err + } + key, err := identityPublicKey(signer) + if err != nil { + return nil, err + } + if err = VerifySignedRecord(rb, sb, &receipt, signer.KeyID, key); err != nil { + return nil, err + } + enrollment, ok := enrollments[receipt.Mirror.ID] + if !ok || enrollment.Role != EnrollmentMirrorOperator || enrollment.Identity != receipt.Mirror { + return nil, errors.New("mirror has no matching committed enrollment") + } + found := false + for scope, chainRefs := range accepted { + if scope.Phase != receipt.Phase || scope.Index != receipt.Index { + continue + } + cb, cs, err := reader.pair(chainRefs) + if err != nil { + return nil, err + } + var chain Chain + coordinatorKey, err := identityPublicKey(d.Coordinator) + if err != nil { + return nil, err + } + if err = VerifySignedRecord(cb, cs, &chain, d.Coordinator.KeyID, coordinatorKey); err != nil { + return nil, err + } + if err = chain.ValidateAgainstDefinition(d); err != nil { + return nil, err + } + if len(chain.Records) != int(scope.Index) { + return nil, errors.New("mirror chain does not match accepted turn") + } + record := chain.Records[len(chain.Records)-1] + files, err := MirrorReceiptFiles(record, chainRefs) + if err != nil { + return nil, err + } + if receipt.AcceptedHeadID != record.RecordID || !slices.Equal(receipt.Files, files) { + return nil, errors.New("mirror receipt does not cover the exact accepted head files") + } + stored, _ := time.Parse(time.RFC3339Nano, receipt.StoredAt) + acceptedAt, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt) + if !stored.After(acceptedAt) { + return nil, errors.New("mirror receipt predates acceptance") + } + if result[scope] == nil { + result[scope] = map[string]bool{} + } + if result[scope][receipt.Mirror.PublicKeyFingerprint] { + return nil, errors.New("duplicate mirror receipt for this head") + } + result[scope][receipt.Mirror.PublicKeyFingerprint] = true + found = true + break + } + if !found { + return nil, errors.New("mirror receipt names no committed accepted head") + } + } + return result, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_public_outputs.go b/internal/mpcceremony/checkpoint_v4_public_outputs.go new file mode 100644 index 00000000..670b4507 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_public_outputs.go @@ -0,0 +1,59 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path/filepath" + + "github.com/consensys/gnark/backend/groth16" + "proof-tool/internal/prover" +) + +// These are the unchanged export checks used by legacy VerifyRelease. Neither +// key generation nor contribution replay is performed. +func verifyCandidateKeyExports(d CeremonyDefinition, candidate CandidateMetadata, dir string) (groth16.VerifyingKey, error) { + if _, err := ReadR1CSFile(filepath.Join(dir, candidate.ConstraintSystem.Name), d.Circuit); err != nil { + return nil, err + } + vk, err := prover.LoadVK(filepath.Join(dir, NativeVerifyingKeyFile)) + if err != nil { + return nil, err + } + if err := verifyCardanoFiles(dir, candidate, vk); err != nil { + return nil, err + } + return vk, nil +} + +// V4 verifies the published example proof without regenerating the setup keys. +// Do not silently add this gate to older released schema verification. +func verifyCandidatePublicOutputsV4(d CeremonyDefinition, candidate CandidateMetadata, dir string) error { + vk, err := verifyCandidateKeyExports(d, candidate, dir) + if err != nil { + return err + } + cardano, format, err := prover.SerializeCardanoVK(vk) + if err != nil { + return err + } + var report VerificationReport + reportRef, err := readCanonicalFile(filepath.Join(dir, candidate.VerificationReport.Name), &report) + if err != nil { + return err + } + reportRef.Name = candidate.VerificationReport.Name + if reportRef != candidate.VerificationReport || report.PublicEvidence != candidate.PublicEvidence { + return errors.New("V4 public report differs from candidate references") + } + if err := validateCandidatePublicReport(report, cardano, format); err != nil { + return err + } + _, evidenceBytes, _, err := loadAndVerifyPublicEvidence(filepath.Join(dir, candidate.PublicEvidence.Name), d.CeremonyID, vk, cardano, candidate.CardanoVerifyingKey) + if err != nil { + return fmt.Errorf("V4 public proof verification: %w", err) + } + if NewDigest(evidenceBytes) != candidate.PublicEvidence.Digest { + return errors.New("V4 verified public evidence differs from candidate reference") + } + return nil +} diff --git a/internal/mpcceremony/checkpoint_v4_record.go b/internal/mpcceremony/checkpoint_v4_record.go new file mode 100644 index 00000000..5af35dca --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_record.go @@ -0,0 +1,116 @@ +package mpcceremony + +import ( + "errors" + "path/filepath" +) + +// RecordedCheckpointV4Options describes an already-created signed protocol +// record which the coordinator wants to add to the authenticated state. The +// helper derives every state projection; callers do not author checkpoint JSON. +type RecordedCheckpointV4Options struct { + Trust TrustPaths + Circuit *CompiledCircuit + ArtifactRoot string + Checkpoint SignedArtifactRefs + Kind CheckpointTransitionKind + Record SignedArtifactRefs + Evidence []ArtifactRef +} + +type RecordedCheckpointV4 struct { + Checkpoint CheckpointV4 + Canonical []byte +} + +func recordableCheckpointKindV4(kind CheckpointTransitionKind) bool { + switch kind { + case CheckpointEnrollmentRecorded, CheckpointMirrorRecorded, CheckpointWitnessRecorded, + CheckpointAuditRecorded, CheckpointIncidentRecorded, + CheckpointPhase1Closed, CheckpointPhase1BeaconRecorded, CheckpointPhase1Sealed, + CheckpointPhase2Initialized, CheckpointPhase2Closed, CheckpointPhase2BeaconRecorded, + CheckpointFinalCandidateRecorded, CheckpointReleaseReviewRecorded, CheckpointFinalReleaseRecorded, CheckpointAborted: + return true + default: + return false + } +} + +// PrepareRecordedCheckpointV4 authenticates the complete predecessor, derives +// the only legal descendant for Kind, and rechecks the exact record/evidence. +// Allocation and candidate acceptance have dedicated APIs; restart and +// rejection remain explicit advanced proposal operations. +func PrepareRecordedCheckpointV4(options RecordedCheckpointV4Options) (RecordedCheckpointV4, error) { + if !recordableCheckpointKindV4(options.Kind) { + return RecordedCheckpointV4{}, errors.New("transition is not supported by record-v4") + } + stored, err := openStoredCheckpointV4(options.Trust, options.ArtifactRoot, options.Checkpoint) + if err != nil { + return RecordedCheckpointV4{}, err + } + defer func() { _ = stored.reader.root.Close() }() + previous := stored.ancestry.head + d := stored.trusted.Definition + + next, err := cloneCheckpointForTurnV4(previous) + if err != nil { + return RecordedCheckpointV4{}, err + } + next.Sequence++ + next.PreviousCheckpoint = &options.Checkpoint + next.Transition = CheckpointTransitionV4{Kind: options.Kind, Record: &options.Record, Evidence: appendUniqueSortedArtifactsV4(nil, options.Evidence...)} + next.AcceptedArtifacts = appendUniqueSortedArtifactsV4(next.AcceptedArtifacts, append(signedArtifacts(&options.Record), options.Evidence...)...) + + switch options.Kind { + case CheckpointPhase1Closed: + next.Progress.Phase1Closure = &options.Record + case CheckpointPhase1BeaconRecorded: + next.Progress.Phase1Beacon = &options.Record + case CheckpointPhase1Sealed: + next.Progress.Phase1Seal = &options.Record + case CheckpointPhase2Initialized: + if options.Circuit == nil || previous.Progress.Phase1Seal == nil { + return RecordedCheckpointV4{}, errors.New("phase2 initialization requires the authenticated phase1 seal and circuit") + } + path := func(ref ArtifactRef) string { return filepath.Join(options.ArtifactRoot, filepath.FromSlash(ref.Name)) } + verified, err := VerifyPhase2GenesisFiles(VerifyPhase2GenesisFilesOptions{ + Trust: options.Trust, Circuit: options.Circuit, TranscriptRoot: options.ArtifactRoot, + Phase1SealPath: path(previous.Progress.Phase1Seal.Record), Phase1SealSignaturePath: path(previous.Progress.Phase1Seal.Signature), + Phase2ChainPath: path(options.Record.Record), Phase2ChainSignaturePath: path(options.Record.Signature), + }) + if err != nil { + return RecordedCheckpointV4{}, err + } + headID, err := verified.Chain.HeadRecordID() + if err != nil { + return RecordedCheckpointV4{}, err + } + next.Progress.Phase2 = &CheckpointPhaseState{Phase: Phase2, HeadRecordID: headID, HeadPayload: verified.Genesis, Chain: verified.ChainRefs} + case CheckpointPhase2Closed: + next.Progress.Phase2Closure = &options.Record + case CheckpointPhase2BeaconRecorded: + next.Progress.Phase2Beacon = &options.Record + case CheckpointFinalCandidateRecorded: + running, err := RunningSoftwareBindingForMode(d.Software.ProofToolVersion, d.Mode) + if err != nil { + return RecordedCheckpointV4{}, err + } + next.Transition.ReplayVerification = &CheckpointReplayVerificationV4{Method: CoordinatorReplayReleaseV1, ToolBinary: running.ToolBinary} + next.Progress.FinalCandidate = &options.Record + case CheckpointReleaseReviewRecorded: + next.Progress.ReleaseReview = &options.Record + case CheckpointFinalReleaseRecorded: + next.Progress.FinalRelease = &options.Record + case CheckpointAborted: + next.Progress.Terminal = &CheckpointTerminalV4{Kind: GovernanceAbort, Record: options.Record} + } + + canonical, err := PrepareCheckpointV4(CheckpointPreparationV4{ + Trust: options.Trust, ArtifactRoot: options.ArtifactRoot, Proposal: next, Circuit: options.Circuit, + RequireCurrentReplayExecutable: true, + }) + if err != nil { + return RecordedCheckpointV4{}, err + } + return RecordedCheckpointV4{Checkpoint: next, Canonical: canonical}, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_release.go b/internal/mpcceremony/checkpoint_v4_release.go new file mode 100644 index 00000000..ac2a4fcf --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_release.go @@ -0,0 +1,266 @@ +package mpcceremony + +import ( + "errors" + "path/filepath" + "slices" + "strings" + + "proof-tool/internal/keybundle" +) + +const FinalReleasePackagePrefixV4 = "final/release/" + +// FinalReleaseInventoryV4 keeps package-relative names distinct from ceremony +// storage locations. It describes a verified private package, not publication. +type FinalReleaseInventoryV4 struct { + prefix string + artifacts []ArtifactRef + members map[string]ArtifactRef +} + +func (i FinalReleaseInventoryV4) PackagePrefix() string { return i.prefix } + +// Artifacts returns a copy; callers cannot change the verified membership. +func (i FinalReleaseInventoryV4) Artifacts() []ArtifactRef { return slices.Clone(i.artifacts) } + +// ValidateFinalReleaseInventoryArtifactsV4 checks a package-relative inventory's +// shape only. It does not authenticate any files or turn a report into authority. +func ValidateFinalReleaseInventoryArtifactsV4(artifacts []ArtifactRef) error { + if len(artifacts) == 0 { + return errors.New("final release inventory must not be empty") + } + if err := validateV4ArtifactSet(artifacts, maxReleaseReviewArtifactsV4+5); err != nil { + return err + } + for _, ref := range artifacts { + if err := validatePortableStorageName(ref.Name); err != nil { + return err + } + if strings.HasPrefix(ref.Name, FinalReleasePackagePrefixV4) { + return errors.New("release inventory must use package-relative artifact names") + } + } + return nil +} + +// Location returns the ceremony-relative location only for an exact inventory +// member. The transport must additionally enforce its own object-key limit. +func (i FinalReleaseInventoryV4) Location(ref ArtifactRef) (string, error) { + if i.prefix != FinalReleasePackagePrefixV4 || i.members[ref.Name] != ref { + return "", errors.New("artifact is not in the final release package inventory") + } + if err := ref.Validate(); err != nil { + return "", err + } + if err := validatePortableStorageName(ref.Name); err != nil { + return "", err + } + if strings.HasPrefix(ref.Name, FinalReleasePackagePrefixV4) { + return "", errors.New("release artifact name already contains the package prefix") + } + return i.prefix + ref.Name, nil +} + +func validateFinalReleaseTransitionV4(t CheckpointTransitionV4) error { + if t.Record == nil || t.Record.Record.Name != FinalReleasePackagePrefixV4+keybundle.ManifestFile || t.Record.Signature.Name != FinalReleasePackagePrefixV4+keybundle.ManifestSignatureFile || len(t.Evidence) != 3 { + return errors.New("final release requires the exact manifest pair and three package bootstrap references") + } + names := []string{FinalReleasePackagePrefixV4 + FinalTranscriptFile, FinalReleasePackagePrefixV4 + ReleaseChecksumsFile, FinalReleasePackagePrefixV4 + keybundle.ManifestPublicKeyFile} + slices.Sort(names) + for j, name := range names { + if t.Evidence[j].Name != name { + return errors.New("final release evidence must name its transcript, checksums and public key") + } + } + return nil +} + +func finalReleaseArtifactLimitV4(ref ArtifactRef) int64 { + switch ref.Name { + case FinalReleasePackagePrefixV4 + FinalTranscriptFile: + return maxFinalTranscriptV3Bytes + case FinalReleasePackagePrefixV4 + ReleaseChecksumsFile: + return maxReleaseChecksumsV4Bytes + default: + if strings.HasSuffix(ref.Name, ".sig") { + return 4096 + } + return maxSignedRecordBytes + } +} + +func requireReleaseReviewPredecessorV4(review ReleaseReviewV4, c CheckpointV4) error { + if c.PreviousCheckpoint == nil || review.ReviewCheckpoint != *c.PreviousCheckpoint { + return errors.New("release package was reviewed against a different checkpoint predecessor") + } + return nil +} + +// finalReleaseDownloadArtifactsV4 derives the complete closed package set from +// the authenticated review and final transition. It reads no package member +// bytes, so a fresh client can download exactly this set before full release +// verification. The names and digests are already bound by the signed ancestry. +func finalReleaseDownloadArtifactsV4(reader *checkpointReaderV4, a checkpointAncestryV4) ([]ArtifactRef, error) { + c := a.head + if c.Transition.Kind != CheckpointFinalReleaseRecorded { + return []ArtifactRef{}, nil + } + if err := validateFinalReleaseTransitionV4(c.Transition); err != nil { + return nil, err + } + if c.Progress.ReleaseReview == nil { + return nil, errors.New("final release lacks its authenticated review") + } + var transcriptRef ArtifactRef + for _, ref := range c.Transition.Evidence { + if ref.Name == FinalReleasePackagePrefixV4+FinalTranscriptFile { + transcriptRef = ref + } + } + if transcriptRef.Name == "" { + return nil, errors.New("final release lacks its exact setup transcript") + } + record, err := reader.read(transcriptRef, maxFinalTranscriptV3Bytes, true) + if err != nil { + return nil, err + } + var transcript FinalTranscript + if err := UnmarshalCanonical(record, &transcript); err != nil { + return nil, err + } + if err := transcript.Validate(); err != nil { + return nil, err + } + if transcript.Schema != FinalTranscriptSchemaV3 || transcript.CeremonyID != c.CeremonyID || transcript.ReleaseReview == nil { + return nil, errors.New("final release setup transcript does not bind this V4 ceremony and review") + } + review := *transcript.ReleaseReview + if review.OperationalBundle != *c.Progress.ReleaseReview { + return nil, errors.New("final release setup transcript names a different operational review") + } + if err := requireReleaseReviewPredecessorV4(review, c); err != nil { + return nil, err + } + names, err := releaseDependencyNamesV4(review.RequiredArtifacts) + if err != nil { + return nil, err + } + refs := make([]ArtifactRef, 0, len(names)+5) + for _, ref := range review.RequiredArtifacts { + name, err := releasePhysicalNameV4(ref.Name) + if err != nil { + return nil, err + } + ref.Name = FinalReleasePackagePrefixV4 + name + refs = append(refs, ref) + } + refs = append(refs, signedArtifacts(c.Transition.Record)...) + refs = append(refs, c.Transition.Evidence...) + slices.SortFunc(refs, compareArtifactRefName) + if len(refs) != len(names)+len(releaseGeneratedNamesV4()) { + return nil, errors.New("final release package inventory is incomplete") + } + if err := validateV4ArtifactSet(refs, maxReleaseReviewArtifactsV4+5); err != nil { + return nil, err + } + for _, ref := range refs { + if !strings.HasPrefix(ref.Name, FinalReleasePackagePrefixV4) { + return nil, errors.New("final release package inventory escaped its closed namespace") + } + } + return refs, nil +} + +// VerifyFinalReleaseCheckpointV4 authenticates the ancestry and all package +// bytes. It does not replay contribution mathematics, authorize production use, +// publish files, or establish that the supplied checkpoint is globally current. +func VerifyFinalReleaseCheckpointV4(trust TrustPaths, root string, head SignedArtifactRefs) (*VerifyReleaseResult, FinalReleaseInventoryV4, error) { + c, err := VerifyStoredCheckpointV4(trust, root, head) + if err != nil { + return nil, FinalReleaseInventoryV4{}, err + } + return verifyFinalReleasePackageV4(trust, root, c) +} + +func verifyFinalReleasePackageV4(trust TrustPaths, root string, c CheckpointV4) (*VerifyReleaseResult, FinalReleaseInventoryV4, error) { + empty := FinalReleaseInventoryV4{} + if c.Transition.Kind != CheckpointFinalReleaseRecorded || c.PreviousCheckpoint == nil { + return nil, empty, errors.New("final release checkpoint with an exact predecessor is required") + } + if err := validateFinalReleaseTransitionV4(c.Transition); err != nil { + return nil, empty, err + } + reader, err := openCheckpointReaderV4(root) + if err != nil { + return nil, empty, err + } + defer func() { _ = reader.root.Close() }() + bootstrap := append(signedArtifacts(c.Transition.Record), c.Transition.Evidence...) + for _, ref := range bootstrap { + if _, err := reader.read(ref, finalReleaseArtifactLimitV4(ref), false); err != nil { + return nil, empty, err + } + } + trusted, err := LoadSignedDefinition(trust) + if err != nil { + return nil, empty, err + } + d := trusted.Definition + dir := filepath.Join(root, filepath.FromSlash(FinalReleasePackagePrefixV4)) + result, err := VerifyReleaseV4(VerifyReleaseV4Options{Trust: trust, KeysDir: dir, TrustedPublicKeyHex: d.ReleaseSigner.Ed25519PublicKeyHex, ExpectedSignatureKeyID: d.ReleaseSigner.KeyID}) + if err != nil { + return nil, empty, err + } + review := result.Transcript.ReleaseReview + if err := requireReleaseReviewPredecessorV4(*review, c); err != nil { + return nil, empty, err + } + names, err := releaseDependencyNamesV4(review.RequiredArtifacts) + if err != nil { + return nil, empty, err + } + names = append(names, releaseGeneratedNamesV4()...) + slices.Sort(names) + expected := make(map[string]ArtifactRef, len(names)) + for _, ref := range review.RequiredArtifacts { + name, err := releasePhysicalNameV4(ref.Name) + if err != nil { + return nil, empty, err + } + ref.Name = name + expected[name] = ref + } + for _, ref := range bootstrap { + ref.Name = strings.TrimPrefix(ref.Name, FinalReleasePackagePrefixV4) + expected[ref.Name] = ref + } + inventory := FinalReleaseInventoryV4{prefix: FinalReleasePackagePrefixV4, artifacts: make([]ArtifactRef, 0, len(names)), members: make(map[string]ArtifactRef, len(names))} + for _, name := range names { + ref, err := artifactRefForFile(name, filepath.Join(dir, filepath.FromSlash(name))) + if err != nil { + return nil, empty, err + } + if ref != expected[name] { + return nil, empty, errors.New("release file changed after verification") + } + inventory.artifacts = append(inventory.artifacts, ref) + inventory.members[ref.Name] = ref + if _, err := inventory.Location(ref); err != nil { + return nil, empty, err + } + } + // Recheck committed bootstrap bytes after package verification/inventory. + for _, ref := range bootstrap { + if _, err := reader.read(ref, finalReleaseArtifactLimitV4(ref), false); err != nil { + return nil, empty, err + } + } + if err := verifyExactReleaseFiles(dir, names, true); err != nil { + return nil, empty, err + } + if err := ValidateFinalReleaseInventoryArtifactsV4(inventory.artifacts); err != nil { + return nil, empty, err + } + return result, inventory, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_release_test.go b/internal/mpcceremony/checkpoint_v4_release_test.go new file mode 100644 index 00000000..72616c0d --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_release_test.go @@ -0,0 +1,255 @@ +package mpcceremony + +import ( + "fmt" + "path/filepath" + "slices" + "strings" + "testing" + + "proof-tool/internal/keybundle" +) + +func releaseTransitionFixtureV4() CheckpointTransitionV4 { + pair := SignedArtifactRefs{Record: checkpointArtifact(FinalReleasePackagePrefixV4+keybundle.ManifestFile, "manifest"), Signature: checkpointArtifact(FinalReleasePackagePrefixV4+keybundle.ManifestSignatureFile, "signature")} + return CheckpointTransitionV4{Kind: CheckpointFinalReleaseRecorded, Record: &pair, Evidence: checkpointArtifacts(checkpointArtifact(FinalReleasePackagePrefixV4+FinalTranscriptFile, "transcript"), checkpointArtifact(FinalReleasePackagePrefixV4+ReleaseChecksumsFile, "checksums"), checkpointArtifact(FinalReleasePackagePrefixV4+keybundle.ManifestPublicKeyFile, "public key"))} +} + +func TestFinalReleaseV4DerivesClosedDownloadInventory(t *testing.T) { + reader, head := finalReleaseDownloadFixtureV4(t) + defer func() { + if err := reader.root.Close(); err != nil { + t.Errorf("close checkpoint reader: %v", err) + } + }() + refs, err := finalReleaseDownloadArtifactsV4(reader, checkpointAncestryV4{head: head}) + if err != nil { + t.Fatal(err) + } + want := []string{ + "final/release/cardano-vk.bin", "final/release/ceremony.json", "final/release/checksums.sha256", + "final/release/manifest-public-key.hex", "final/release/manifest.json", "final/release/manifest.sig", + "final/release/ownership.pk", "final/release/ownership.vk", "final/release/setup-transcript.json", + } + names := make([]string, len(refs)) + for i, ref := range refs { + names[i] = ref.Name + if filepath.ToSlash(ref.Name) != ref.Name { + t.Fatalf("non-portable inventory name %q", ref.Name) + } + } + if !slices.Equal(names, want) { + t.Fatalf("inventory names = %v, want %v", names, want) + } +} + +// finalReleaseDownloadFixtureV4 models the real layout: Progress.ReleaseReview +// is the signed operational-bundle pair, while the ReleaseReviewV4 lives inside +// the signed final/release/setup-transcript.json. Keeping those two record +// types distinct prevents a final checkpoint from treating an evidence bundle +// as a release review merely because both are canonical JSON. +func finalReleaseDownloadFixtureV4(t *testing.T) (*checkpointReaderV4, CheckpointV4) { + t.Helper() + root := t.TempDir() + d, candidate, review := transcriptFixtureV3(t) + transcript, err := newFinalTranscriptV3(d, candidate, review) + if err != nil { + t.Fatal(err) + } + raw, err := MarshalCanonical(transcript) + if err != nil { + t.Fatal(err) + } + tx := releaseTransitionFixtureV4() + for n := range tx.Evidence { + if tx.Evidence[n].Name == FinalReleasePackagePrefixV4+FinalTranscriptFile { + tx.Evidence[n].Digest = NewDigest(raw) + writeFixtureFile(t, root, tx.Evidence[n].Name, raw) + } + } + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + head := CheckpointV4{CeremonyID: d.CeremonyID, PreviousCheckpoint: &review.ReviewCheckpoint, Transition: tx, Progress: CheckpointProgressV4{ReleaseReview: &review.OperationalBundle, FinalRelease: tx.Record}} + return reader, head +} + +func TestFinalReleaseV4InventoryRejectsUnboundTranscript(t *testing.T) { + for name, change := range map[string]func(*CheckpointV4){ + "missing setup transcript": func(head *CheckpointV4) { + filtered := head.Transition.Evidence[:0] + for _, ref := range head.Transition.Evidence { + if ref.Name != FinalReleasePackagePrefixV4+FinalTranscriptFile { + filtered = append(filtered, ref) + } + } + head.Transition.Evidence = filtered + }, + "different operational bundle": func(head *CheckpointV4) { + pair := checkpointSigned("operational/different-bundle") + head.Progress.ReleaseReview = &pair + }, + "different review predecessor": func(head *CheckpointV4) { + pair := checkpointSigned("checkpoints/different-review") + head.PreviousCheckpoint = &pair + }, + } { + t.Run(name, func(t *testing.T) { + reader, head := finalReleaseDownloadFixtureV4(t) + defer func() { _ = reader.root.Close() }() + change(&head) + if _, err := finalReleaseDownloadArtifactsV4(reader, checkpointAncestryV4{head: head}); err == nil { + t.Fatal("unbound final transcript inventory accepted") + } + }) + } +} + +func TestFinalReleaseV4CanonicalBootstrap(t *testing.T) { + tx := releaseTransitionFixtureV4() + if err := tx.Validate(); err != nil { + t.Fatal(err) + } + for _, change := range []func(*CheckpointTransitionV4){ + func(x *CheckpointTransitionV4) { x.Record.Record.Name = "other/manifest.json" }, + func(x *CheckpointTransitionV4) { x.Record.Signature.Name = "other/manifest.sig" }, + func(x *CheckpointTransitionV4) { x.Evidence = x.Evidence[:2] }, + func(x *CheckpointTransitionV4) { + x.Evidence = appendCheckpointArtifacts(x.Evidence, checkpointArtifact("final/release/extra.txt", "extra")) + }, + func(x *CheckpointTransitionV4) { x.Evidence[0].Name = "final/release/other.json" }, + } { + bad := releaseTransitionFixtureV4() + change(&bad) + if err := bad.Validate(); err == nil { + t.Fatal("incorrect bootstrap accepted") + } + } +} + +func TestFinalReleaseV4ExactReviewPredecessor(t *testing.T) { + pair := checkpointSigned("checkpoints/review") + review := ReleaseReviewV4{ReviewCheckpoint: pair} + c := CheckpointV4{PreviousCheckpoint: &pair} + if err := requireReleaseReviewPredecessorV4(review, c); err != nil { + t.Fatal(err) + } + for _, change := range []func(*SignedArtifactRefs){ + func(p *SignedArtifactRefs) { p.Record.Name = "checkpoints/older.json" }, + func(p *SignedArtifactRefs) { p.Record.Digest = NewDigest([]byte("older head")) }, + func(p *SignedArtifactRefs) { p.Signature.Digest = NewDigest([]byte("another signature")) }, + func(p *SignedArtifactRefs) { p.Signature.Name = "checkpoints/another.sig" }, + } { + wrong := pair + change(&wrong) + c.PreviousCheckpoint = &wrong + if err := requireReleaseReviewPredecessorV4(review, c); err == nil { + t.Fatal("different predecessor accepted") + } + } + c.PreviousCheckpoint = nil + if err := requireReleaseReviewPredecessorV4(review, c); err == nil { + t.Fatal("missing predecessor accepted") + } +} + +func TestFinalReleaseV4InventoryLocations(t *testing.T) { + r := checkpointArtifact(strings.Repeat("a", 512), "payload") + i := FinalReleaseInventoryV4{prefix: FinalReleasePackagePrefixV4, artifacts: []ArtifactRef{r}, members: map[string]ArtifactRef{r.Name: r}} + if got, err := i.Location(r); err != nil || got != FinalReleasePackagePrefixV4+r.Name { + t.Fatalf("maximum name: %s %v", got, err) + } + wrong := r + wrong.Digest = NewDigest([]byte("different")) + copy := i.Artifacts() + copy[0] = wrong + if i.Artifacts()[0] != r { + t.Fatal("accessor exposed mutable membership") + } + if _, err := i.Location(wrong); err == nil { + t.Fatal("wrong digest accepted") + } + r.Name = FinalReleasePackagePrefixV4 + "manifest.json" + i.artifacts = []ArtifactRef{r} + i.members = map[string]ArtifactRef{r.Name: r} + if _, err := i.Location(r); err == nil { + t.Fatal("double prefix accepted") + } + i.prefix = "other/" + if _, err := i.Location(r); err == nil { + t.Fatal("wrong prefix accepted") + } + if _, err := (FinalReleaseInventoryV4{}).Location(r); err == nil { + t.Fatal("unverified zero inventory accepted") + } +} + +func TestFinalReleaseV4ReadLimits(t *testing.T) { + if got := finalReleaseArtifactLimitV4(checkpointArtifact(FinalReleasePackagePrefixV4+FinalTranscriptFile, "x")); got != maxFinalTranscriptV3Bytes { + t.Fatal(got) + } + if got := finalReleaseArtifactLimitV4(checkpointArtifact("other/"+FinalTranscriptFile, "x")); got != maxSignedRecordBytes { + t.Fatal("unrelated JSON limit widened") + } + if got := finalReleaseArtifactLimitV4(checkpointArtifact(FinalReleasePackagePrefixV4+ReleaseChecksumsFile, "x")); got != maxReleaseChecksumsV4Bytes { + t.Fatal(got) + } +} + +func TestFinalReleaseV4SequenceCapacity(t *testing.T) { + // Non-delivery edges consume at least a fresh signed pair. Governance + // authoring rejects a previously committed record, too. A delivery slot + // can be allocated once and become terminal once; counting both separately + // overestimates combined receipt/retire-and-reallocate transitions. + // Initial state is sequence zero. Reserve one further final release edge. + upper := (MaxCheckpointArtifacts-5)/2 + 2*MaxDeliverySlotsV2 + 1 + if upper >= MaxCheckpointSequenceV4 { + t.Fatalf("legal evidence/delivery bounds can exhaust release sequence capacity: %d", upper) + } +} + +// Synthetic capacity boundary; the real fixture separately checks semantic +// authoring. Extra references here stand for previously accepted evidence. +func TestFinalReleaseV4InventoryCapacity(t *testing.T) { + _, c, _, _ := checkpointFixtureV4(t) + c.Sequence = 1 + previous := checkpointSigned("checkpoints/previous") + c.PreviousCheckpoint = &previous + for _, dst := range []**SignedArtifactRefs{&c.Progress.Phase1Closure, &c.Progress.Phase1Beacon, &c.Progress.Phase1Seal, &c.Progress.Phase2Closure, &c.Progress.Phase2Beacon, &c.Progress.FinalCandidate, &c.Progress.ReleaseReview} { + pair := checkpointSigned(fmt.Sprintf("stages/%d", len(c.AcceptedArtifacts))) + *dst = &pair + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts, pair.Record, pair.Signature) + } + p2 := checkpointSigned("phase2/chain") + payload := checkpointArtifact("phase2/payload.bin", "payload") + c.Progress.Phase2 = &CheckpointPhaseState{Phase: Phase2, HeadRecordID: NewDigest([]byte("p2")).SHA256, HeadPayload: payload, Chain: p2} + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts, p2.Record, p2.Signature, payload) + c.Transition = CheckpointTransitionV4{Kind: CheckpointReleaseReviewRecorded, Record: c.Progress.ReleaseReview, Evidence: []ArtifactRef{}} + for len(c.AcceptedArtifacts) < MaxCheckpointArtifacts { + c.AcceptedArtifacts = append(c.AcceptedArtifacts, checkpointArtifact(fmt.Sprintf("padding/%05d", len(c.AcceptedArtifacts)), "evidence")) + } + c.AcceptedArtifacts = checkpointArtifacts(c.AcceptedArtifacts...) + if err := c.Validate(); err != nil { + t.Fatal(err) + } + bad := cloneCheckpointV4(t, c) + bad.AcceptedArtifacts = appendCheckpointArtifacts(bad.AcceptedArtifacts, checkpointArtifact("padding/overflow", "x")) + if err := bad.Validate(); err == nil { + t.Fatal("pre-release capacity widened") + } + tx := releaseTransitionFixtureV4() + next := nextCheckpointV4(t, c, tx) + next.Progress.FinalRelease = tx.Record + if len(next.AcceptedArtifacts) != MaxCheckpointArtifacts+5 { + t.Fatal("incorrect test inventory") + } + if err := ValidateCheckpointTransitionV4(c, next); err != nil { + t.Fatal(err) + } + bad = cloneCheckpointV4(t, next) + bad.AcceptedArtifacts = appendCheckpointArtifacts(bad.AcceptedArtifacts, checkpointArtifact("padding/sixth", "x")) + if err := ValidateCheckpointTransitionV4(c, bad); err == nil { + t.Fatal("sixth final reference accepted") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_review.go b/internal/mpcceremony/checkpoint_v4_review.go new file mode 100644 index 00000000..5430aed5 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_review.go @@ -0,0 +1,289 @@ +package mpcceremony + +import ( + "bytes" + "errors" + "fmt" + "path" + "path/filepath" + "reflect" + "slices" + "strings" + "time" +) + +// ReleaseReviewV4 is a deterministic local result, not a signed authorization. +// Signing/packaging must recompute it against the same exact checkpoint. +type ReleaseReviewV4 struct { + CeremonyID string `json:"ceremony_id"` + ReviewCheckpoint SignedArtifactRefs `json:"review_checkpoint"` + FinalCandidateCheckpoint SignedArtifactRefs `json:"final_candidate_checkpoint"` + CandidateArtifacts []ArtifactRef `json:"candidate_artifacts"` + RequiredArtifacts []ArtifactRef `json:"required_artifacts"` + OperationalBundle SignedArtifactRefs `json:"operational_bundle"` + Audits []SignedArtifactRefs `json:"audits"` + ReplayVerification CheckpointReplayVerificationV4 `json:"replay_verification"` + ReleasedAt string `json:"released_at"` +} + +func (r ReleaseReviewV4) Validate() error { + if err := validateHashID("ceremony_id", r.CeremonyID); err != nil { + return err + } + for _, pair := range []SignedArtifactRefs{r.ReviewCheckpoint, r.FinalCandidateCheckpoint, r.OperationalBundle} { + if err := pair.Validate(); err != nil { + return err + } + } + if err := validateV4ArtifactSet(r.CandidateArtifacts, MaxCheckpointArtifacts); err != nil { + return err + } + if len(r.CandidateArtifacts) == 0 { + return errors.New("review requires exact candidate files") + } + if len(r.RequiredArtifacts) == 0 { + return errors.New("review requires exact dependency files") + } + if err := validateV4ArtifactSet(r.RequiredArtifacts, maxReleaseReviewArtifactsV4); err != nil { + return err + } + if r.Audits == nil { + return errors.New("review requires explicit audits") + } + if len(r.Audits) > 0 { + if err := validateSignedArtifactSet("audits", r.Audits); err != nil { + return err + } + } + if r.ReplayVerification.Method != CoordinatorReplayReleaseV1 { + return errors.New("review requires coordinator full replay claim") + } + if err := r.ReplayVerification.ToolBinary.Validate(); err != nil { + return err + } + return validateTimestamp("released_at", r.ReleasedAt) +} + +// VerifyReleaseReviewV4 verifies signatures, exact files, lifecycle consistency +// and required evidence. It trusts the coordinator's approved replay claim; +// it never calls contribution replay and accepts no replay/circuit input. +func VerifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRefs SignedArtifactRefs, releasedAt time.Time) (ReleaseReviewV4, error) { + return verifyReleaseReviewV4(trust, artifactRoot, head, bundleRefs, releasedAt, false) +} + +func verifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRefs SignedArtifactRefs, releasedAt time.Time, flatCandidate bool) (ReleaseReviewV4, error) { + if releasedAt.IsZero() || releasedAt.Location() != time.UTC { + return ReleaseReviewV4{}, errors.New("release time must be nonzero UTC") + } + if bundleRefs.Record.Name != OperationalEvidenceBundleFile || bundleRefs.Signature.Name != OperationalEvidenceSignatureFile { + return ReleaseReviewV4{}, errors.New("review requires the canonical operational bundle pair") + } + trusted, err := loadOperationalCeremony(trust) + if err != nil { + return ReleaseReviewV4{}, err + } + d := trusted.Definition + if d.Schema != DefinitionSchemaV4 { + return ReleaseReviewV4{}, errors.New("this review API requires definition V4; legacy replay rules are unchanged") + } + db, err := MarshalCanonical(d) + if err != nil { + return ReleaseReviewV4{}, err + } + ds, err := readRegularBounded(trust.DefinitionSignaturePath, 4096) + if err != nil { + return ReleaseReviewV4{}, err + } + reader, err := openCheckpointReaderV4(artifactRoot) + if err != nil { + return ReleaseReviewV4{}, err + } + defer func() { _ = reader.root.Close() }() + reader.flatCandidate = flatCandidate + a, err := loadCheckpointAncestryV4(reader, d, db, ds, head) + if err != nil { + return ReleaseReviewV4{}, err + } + if a.head.Progress.Terminal != nil || a.head.Progress.FinalRelease != nil || a.finalCandidateCheckpoint == nil || a.head.Progress.ReleaseReview == nil { + return ReleaseReviewV4{}, errors.New("review requires an unreleased final candidate and its signed operational bundle checkpoint") + } + if *a.head.Progress.ReleaseReview != bundleRefs { + return ReleaseReviewV4{}, errors.New("review bundle differs from the exact signed checkpoint") + } + raw, sig, err := reader.pair(*a.finalCandidateCheckpoint) + if err != nil { + return ReleaseReviewV4{}, err + } + final, err := VerifySignedCheckpointV4(d, db, ds, raw, sig) + if err != nil { + return ReleaseReviewV4{}, err + } + if final.Transition.Kind != CheckpointFinalCandidateRecorded || !reflect.DeepEqual(final.Progress.FinalCandidate, a.head.Progress.FinalCandidate) { + return ReleaseReviewV4{}, errors.New("review candidate differs from its committed replay claim") + } + if final.Transition.Record.Record.Name != "final/candidate/"+CandidateMetadataFile || final.Transition.Record.Signature.Name != "final/candidate/"+CandidateSignatureFile { + return ReleaseReviewV4{}, errors.New("review requires the canonical final candidate pair") + } + for _, ref := range append(signedArtifacts(final.Transition.Record), final.Transition.Evidence...) { + limit := MaxArtifactSize + if strings.HasSuffix(ref.Name, ".json") || strings.HasSuffix(ref.Name, ".sig") || strings.HasSuffix(ref.Name, ".txt") { + limit = maxSignedRecordBytes + } + if _, err := reader.read(ref, limit, false); err != nil { + return ReleaseReviewV4{}, err + } + } + // Logical names belong to the authenticated protocol, not the location + // where the signer saved its independently trusted local copy. + definitionRef := a.head.Definition.Record + candidateDir := filepath.Join(reader.path, "final/candidate") + if flatCandidate { + candidateDir = reader.path + } + candidate, candidateRef, err := verifyCandidate(d, definitionRef, candidateDir) + if err != nil { + return ReleaseReviewV4{}, err + } + verifyTree := verifyCandidateClosedTree + if flatCandidate { + verifyTree = verifyCandidateSubsetV4 + } + candidate, inventory, err := verifyTree(d, definitionRef, candidateDir, candidate, candidateRef) + if err != nil { + return ReleaseReviewV4{}, err + } + for i := range inventory { + inventory[i].Name = "final/candidate/" + inventory[i].Name + } + want := append(signedArtifacts(final.Transition.Record), final.Transition.Evidence...) + slices.SortFunc(want, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + if !slices.Equal(inventory, want) { + return ReleaseReviewV4{}, errors.New("review differs from committed closed candidate inventory") + } + if err := verifyReviewLifecycleV4(reader, trusted, a.head.Progress, candidate, candidateDir); err != nil { + return ReleaseReviewV4{}, err + } + if err := verifyCandidatePublicOutputsV4(d, candidate, candidateDir); err != nil { + return ReleaseReviewV4{}, err + } + bb, bs, err := reader.pair(bundleRefs) + if err != nil { + return ReleaseReviewV4{}, err + } + var bundle OperationalEvidenceBundle + if err := VerifySignedRecord(bb, bs, &bundle, d.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { + return ReleaseReviewV4{}, err + } + assembledAt, _ := time.Parse(time.RFC3339Nano, bundle.AssembledAt) + derived, err := PrepareOperationalBundleV4(trust, artifactRoot, head, assembledAt) + if err != nil { + return ReleaseReviewV4{}, err + } + canonical, err := MarshalCanonical(derived.Bundle) + if err != nil { + return ReleaseReviewV4{}, err + } + if !bytes.Equal(canonical, bb) { + return ReleaseReviewV4{}, errors.New("signed bundle does not match the exact review checkpoint") + } + operational, err := verifyReleaseOperationalEvidence(d, trusted.CoordinatorPublicKey, candidate, reader.path, filepath.Join(reader.path, bundleRefs.Record.Name), filepath.Join(reader.path, bundleRefs.Signature.Name), releasedAt) + if err != nil { + return ReleaseReviewV4{}, err + } + enrollments, err := loadCheckpointEnrollmentsV4(reader, d, db, a.enrollments) + if err != nil { + return ReleaseReviewV4{}, err + } + audits := sortedSignedRefsV4(a.audits) + latest, err := verifyCheckpointAuditsV4(reader, d, a.head.Progress, enrollments, audits, true) + if err != nil { + return ReleaseReviewV4{}, err + } + finalizedAt, _ := time.Parse(time.RFC3339Nano, candidate.FinalizedAt) + if err := validateReleaseChronology(releasedAt, finalizedAt, latest); err != nil { + return ReleaseReviewV4{}, err + } + result := ReleaseReviewV4{CeremonyID: d.CeremonyID, ReviewCheckpoint: head, FinalCandidateCheckpoint: *a.finalCandidateCheckpoint, CandidateArtifacts: inventory, OperationalBundle: bundleRefs, Audits: audits, ReplayVerification: *final.Transition.ReplayVerification, ReleasedAt: releasedAt.Format(time.RFC3339Nano)} + result.RequiredArtifacts, err = releaseReviewDependenciesV4(reader, a, inventory, bundleRefs, operational.Verified.ReferencedArtifacts, audits) + if err != nil { + return ReleaseReviewV4{}, err + } + if err := result.Validate(); err != nil { + return ReleaseReviewV4{}, err + } + // Bind every returned dependency to bytes at this root, including the + // definition pair when the independently trusted local copy lives elsewhere. + for _, ref := range result.RequiredArtifacts { + if _, err := reader.read(ref, MaxArtifactSize, false); err != nil { + return ReleaseReviewV4{}, err + } + } + return result, nil +} + +// Reuse the existing record validators and summary derivation, stopping before +// replayAll. All inputs come from the exact checkpoint, not alternative paths. +func verifyReviewLifecycleV4(reader *checkpointReaderV4, trusted *TrustedCeremony, p CheckpointProgressV4, candidate CandidateMetadata, candidateDir string) error { + if p.Phase1Closure == nil || p.Phase1Beacon == nil || p.Phase1Seal == nil || p.Phase2 == nil || p.Phase2Closure == nil || p.Phase2Beacon == nil { + return errors.New("review requires both completed phases") + } + r := loadedReplay{definition: trusted.Definition, phase1ChainRef: p.Phase1.Chain.Record, phase2ChainRef: p.Phase2.Chain.Record} + // Existing final metadata uses basenames for signed chain summaries. + r.phase1ChainRef.Name = path.Base(r.phase1ChainRef.Name) + r.phase2ChainRef.Name = path.Base(r.phase2ChainRef.Name) + for _, item := range []struct { + refs SignedArtifactRefs + record any + }{ + {p.Phase1.Chain, &r.phase1Chain}, {*p.Phase1Closure, &r.phase1Close}, {*p.Phase1Beacon, &r.phase1Beacon}, {*p.Phase1Seal, &r.phase1Seal}, + {p.Phase2.Chain, &r.phase2Chain}, {*p.Phase2Closure, &r.phase2Close}, {*p.Phase2Beacon, &r.phase2Beacon}, + } { + raw, sig, err := reader.pair(item.refs) + if err != nil { + return err + } + if err := VerifySignedRecord(raw, sig, item.record, trusted.Definition.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { + return err + } + } + if err := validateReplayRecords(r); err != nil { + return err + } + if err := validatePhase2CloseBoundaryV4(r.phase1Close, r.phase1Beacon, r.phase2Close); err != nil { + return err + } + for _, item := range []struct { + close CloseRecord + beacon BeaconRecord + }{{r.phase1Close, r.phase1Beacon}, {r.phase2Close, r.phase2Beacon}} { + if _, err := reader.read(item.beacon.RawResponse, maxSignedRecordBytes, false); err != nil { + return err + } + if err := VerifyBeaconRecordFiles(trusted, reader.path, item.close, item.beacon); err != nil { + return err + } + } + seal, err := loadCandidatePhase2Seal(trusted.Definition, candidate, candidateDir) + if err != nil { + return err + } + if err := ValidateSeal(r.phase2Close, r.phase2Beacon, seal); err != nil { + return err + } + first, err := phaseSummary(r.phase1Chain, r.phase1ChainRef, r.phase1Close, r.phase1Beacon, r.phase1Seal) + if err != nil { + return err + } + second, err := phaseSummary(r.phase2Chain, r.phase2ChainRef, r.phase2Close, r.phase2Beacon, seal) + if err != nil { + return err + } + var report VerificationReport + if _, err := readCanonicalFile(filepath.Join(candidateDir, candidate.VerificationReport.Name), &report); err != nil { + return err + } + if err := validateCandidateReplayClaims(candidate, first, second, seal, report); err != nil { + return fmt.Errorf("review lifecycle: %w", err) + } + return nil +} diff --git a/internal/mpcceremony/checkpoint_v4_review_files.go b/internal/mpcceremony/checkpoint_v4_review_files.go new file mode 100644 index 00000000..9e7c4997 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_review_files.go @@ -0,0 +1,62 @@ +package mpcceremony + +import ( + "fmt" + "slices" + "strings" +) + +// Checkpoint files are additional to their accepted artifact inventories. +// The small allowance covers the definition and signed operational bundle. +const maxReleaseReviewArtifactsV4 = MaxCheckpointArtifacts + 2*(MaxCheckpointSequenceV4+1) + 16 + +// This is a verification dependency set, not a full independent-replay archive. +// Historical contribution payloads are not read by this review and are omitted. +func releaseReviewDependenciesV4(reader *checkpointReaderV4, a checkpointAncestryV4, candidate []ArtifactRef, bundle SignedArtifactRefs, operational []ArtifactRef, audits []SignedArtifactRefs) ([]ArtifactRef, error) { + refs := append([]ArtifactRef{}, candidate...) + refs = append(refs, operational...) + pairs := append([]SignedArtifactRefs{a.head.Definition, bundle}, a.checkpoints...) + pairs = append(pairs, audits...) + p := a.head.Progress + pairs = append(pairs, p.Phase1.Chain, *p.Phase1Closure, *p.Phase1Beacon, *p.Phase1Seal, p.Phase2.Chain, *p.Phase2Closure, *p.Phase2Beacon) + for _, pair := range pairs { + refs = append(refs, pair.Record, pair.Signature) + } + for _, pair := range []SignedArtifactRefs{*p.Phase1Beacon, *p.Phase2Beacon} { + raw, _, err := reader.pair(pair) + if err != nil { + return nil, err + } + var beacon BeaconRecord + if err := UnmarshalCanonical(raw, &beacon); err != nil { + return nil, err + } + refs = append(refs, beacon.RawResponse) + } + return uniqueReleaseReviewArtifactsV4(refs) +} + +func uniqueReleaseReviewArtifactsV4(refs []ArtifactRef) ([]ArtifactRef, error) { + refs = slices.Clone(refs) + slices.SortFunc(refs, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + unique := refs[:0] + for _, ref := range refs { + if err := ref.Validate(); err != nil { + return nil, err + } + if err := validatePortableStorageName(ref.Name); err != nil { + return nil, err + } + if len(unique) > 0 && unique[len(unique)-1].Name == ref.Name { + if unique[len(unique)-1] != ref { + return nil, fmt.Errorf("review dependency %q has conflicting digests", ref.Name) + } + continue + } + unique = append(unique, ref) + } + if err := validateV4ArtifactSet(unique, maxReleaseReviewArtifactsV4); err != nil { + return nil, err + } + return unique, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_review_files_test.go b/internal/mpcceremony/checkpoint_v4_review_files_test.go new file mode 100644 index 00000000..e615ab43 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_review_files_test.go @@ -0,0 +1,25 @@ +package mpcceremony + +import ( + "slices" + "testing" +) + +func TestReleaseReviewDependenciesV4Unique(t *testing.T) { + a := ArtifactRef{Name: "a.json", Digest: NewDigest([]byte("a"))} + b := ArtifactRef{Name: "b.json", Digest: NewDigest([]byte("b"))} + got, err := uniqueReleaseReviewArtifactsV4([]ArtifactRef{b, a, b}) + if err != nil || !slices.Equal(got, []ArtifactRef{a, b}) { + t.Fatalf("deterministic unique union: %v, %v", got, err) + } + changed := a + changed.Digest = b.Digest + if _, err := uniqueReleaseReviewArtifactsV4([]ArtifactRef{a, changed}); err == nil { + t.Fatal("conflicting artifact accepted") + } + invalid := a + invalid.Name = "../outside.json" + if _, err := uniqueReleaseReviewArtifactsV4([]ArtifactRef{invalid}); err == nil { + t.Fatal("escaping artifact accepted") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_review_test.go b/internal/mpcceremony/checkpoint_v4_review_test.go new file mode 100644 index 00000000..d92aa42c --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_review_test.go @@ -0,0 +1,109 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" +) + +// Called after the real Linux fixture finishes, in its private test directory. +// This makes signatures/checksums internally consistent with a bad public +// proof, demonstrating that the new check is more than another file hash. +func testV4CoherentInvalidPublicProof(t *testing.T, root string) { + t.Helper() + var d CeremonyDefinition + definitionRef, err := readCanonicalFile(filepath.Join(root, "ceremony.json"), &d) + if err != nil { + t.Fatal(err) + } + dir := filepath.Join(root, "final/candidate") + var candidate CandidateMetadata + if _, err := readCanonicalFile(filepath.Join(dir, CandidateMetadataFile), &candidate); err != nil { + t.Fatal(err) + } + if err := verifyCandidatePublicOutputsV4(d, candidate, dir); err != nil { + t.Fatalf("original public proof: %v", err) + } + changedReport := candidate + changedReport.VerificationReport.Digest = NewDigest([]byte("different report")) + if err := verifyCandidatePublicOutputsV4(d, changedReport, dir); err == nil || !strings.Contains(err.Error(), "public report differs") { + t.Fatalf("public output gate did not bind the report bytes it read: %v", err) + } + // Preserve a valid proof but make the report/candidate point to other bytes. + // The verifier must bind the bytes it actually verified, not just the report. + reportPath := filepath.Join(dir, candidate.VerificationReport.Name) + originalReport, err := os.ReadFile(reportPath) + if err != nil { + t.Fatal(err) + } + var changedEvidenceReport VerificationReport + if err := UnmarshalCanonical(originalReport, &changedEvidenceReport); err != nil { + t.Fatal(err) + } + changedEvidence := candidate + changedEvidence.PublicEvidence.Digest = NewDigest([]byte("different evidence")) + changedEvidenceReport.PublicEvidence = changedEvidence.PublicEvidence + changedReportBytes, err := MarshalCanonical(changedEvidenceReport) + if err != nil { + t.Fatal(err) + } + changedEvidence.VerificationReport = putCheckpointTestFileV4(t, dir, candidate.VerificationReport.Name, changedReportBytes) + if err := verifyCandidatePublicOutputsV4(d, changedEvidence, dir); err == nil || !strings.Contains(err.Error(), "verified public evidence differs") { + t.Fatalf("public output gate did not bind verified evidence bytes: %v", err) + } + putCheckpointTestFileV4(t, dir, candidate.VerificationReport.Name, originalReport) + var evidence PublicFinalizationEvidence + if _, err := readCanonicalFile(filepath.Join(dir, candidate.PublicEvidence.Name), &evidence); err != nil { + t.Fatal(err) + } + proof, err := hex.DecodeString(evidence.CardanoProofHex) + if err != nil { + t.Fatal(err) + } + clear(proof) + evidence.CardanoProofHex = hex.EncodeToString(proof) + evidence.CardanoProofRawDigest = NewDigest(proof) + raw, err := MarshalCanonical(evidence) + if err != nil { + t.Fatal(err) + } + candidate.PublicEvidence = putCheckpointTestFileV4(t, dir, candidate.PublicEvidence.Name, raw) + var report VerificationReport + if _, err := readCanonicalFile(filepath.Join(dir, candidate.VerificationReport.Name), &report); err != nil { + t.Fatal(err) + } + report.PublicEvidence = candidate.PublicEvidence + report.CardanoProofRawDigest = evidence.CardanoProofRawDigest + raw, err = MarshalCanonical(report) + if err != nil { + t.Fatal(err) + } + candidate.VerificationReport = putCheckpointTestFileV4(t, dir, candidate.VerificationReport.Name, raw) + candidate, err = NewCandidateMetadata(candidate) + if err != nil { + t.Fatal(err) + } + raw, sig, err := SignRecord(candidate, d.Coordinator.KeyID, ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x81}, 32))) + if err != nil { + t.Fatal(err) + } + putCheckpointTestFileV4(t, dir, CandidateMetadataFile, raw) + putCheckpointTestFileV4(t, dir, CandidateSignatureFile, sig) + checksums := filepath.Join(dir, CandidateChecksumsFile) + if err := os.Remove(checksums); err != nil { + t.Fatal(err) + } + if err := writeChecksumsNoReplace(dir, checksums, candidateChecksumNames()); err != nil { + t.Fatal(err) + } + if _, _, err := verifyCandidate(d, definitionRef, dir); err != nil { + t.Fatalf("coherent fixture unexpectedly failed its metadata/hash gate: %v", err) + } + if err := verifyCandidatePublicOutputsV4(d, candidate, dir); err == nil || !strings.Contains(err.Error(), "V4 public proof verification") { + t.Fatalf("coherent invalid public proof did not reach proof gate: %v", err) + } +} diff --git a/internal/mpcceremony/checkpoint_v4_test.go b/internal/mpcceremony/checkpoint_v4_test.go new file mode 100644 index 00000000..314458ca --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -0,0 +1,491 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "encoding/json" + "fmt" + "strings" + "testing" + + "proof-tool/internal/keybundle" +) + +// These fixtures test structural guidance transitions, not contribution math. +// A real signed ceremony round trip separately exercises semantic authoring. +func checkpointFixtureV4(t *testing.T) (CeremonyDefinition, CheckpointV4, []byte, []byte) { + t.Helper() + d := trustedCoordinatorDefinition(t) + d.Mode = ModeRehearsal + d.AssurancePolicy.ExternalSecurityAuditSignoffs = 0 + d.Phase1Policy.Minimum = 1 + d.Phase2Policy.Minimum = 1 + var err error + d, err = FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, 32)) + db, ds, err := SignRecord(d, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + def := SignedArtifactRefs{Record: inventoryTestRef("ceremony.json", db), Signature: inventoryTestRef("ceremony.sig", ds)} + chain := checkpointSigned("phase1/chain-0000") + c := CheckpointV4{Schema: CheckpointSchemaV4, Workflow: StorageFirstWorkflowV2, CeremonyID: d.CeremonyID, Definition: def, + AssurancePolicy: cloneAssurancePolicy(d.AssurancePolicy), ReleaseVerification: CoordinatorReplayReleaseV1, + Transition: CheckpointTransitionV4{Kind: CheckpointInitial, Evidence: []ArtifactRef{}}, + Progress: CheckpointProgressV4{Phase1: CheckpointPhaseState{Phase: Phase1, HeadRecordID: NewDigest([]byte("head-0")).SHA256, HeadPayload: d.Phase1Genesis, Chain: chain}}, + AcceptedArtifacts: checkpointArtifacts(def.Record, def.Signature, d.Circuit.R1CS, chain.Record, chain.Signature, d.Phase1Genesis), Deliveries: []DeliverySlotV2{}} + if err := c.Validate(); err != nil { + t.Fatal(err) + } + return d, c, db, ds +} + +func cloneCheckpointV4(t *testing.T, c CheckpointV4) CheckpointV4 { + t.Helper() + raw, err := json.Marshal(c) + if err != nil { + t.Fatal(err) + } + var out CheckpointV4 + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} + +func nextCheckpointV4(t *testing.T, p CheckpointV4, transition CheckpointTransitionV4) CheckpointV4 { + t.Helper() + c := cloneCheckpointV4(t, p) + raw, err := MarshalCanonical(p) + if err != nil { + t.Fatal(err) + } + ref := checkpointSigned(fmt.Sprintf("checkpoints/%04d", p.Sequence)) + ref.Record.Digest = NewDigest(raw) + c.PreviousCheckpoint = &ref + c.Sequence++ + c.Transition = transition + c.AcceptedArtifacts = appendCheckpointArtifacts(c.AcceptedArtifacts, append(signedArtifacts(transition.Record), transition.Evidence...)...) + return c +} + +func checkpointTurnV4(t *testing.T, d CeremonyDefinition, start CheckpointV4, phase Phase) []CheckpointV4 { + t.Helper() + state := start.Progress.Phase1 + participant := d.Phase1Policy.Participants[0] + allocate, accept := CheckpointPhase1CandidateAllocated, CheckpointPhase1CandidateAccepted + if phase == Phase2 { + state = *start.Progress.Phase2 + participant = d.Phase2Policy.Participants[0] + allocate = CheckpointPhase2CandidateAllocated + accept = CheckpointPhase2CandidateAccepted + } + scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: phase, Index: state.AcceptedCount + 1, ParticipantID: participant, ParentHeadID: state.HeadRecordID} + id := fmt.Sprintf("%032x", start.Sequence+100) + c1 := nextCheckpointV4(t, start, CheckpointTransitionV4{Kind: allocate, Scope: &scope, AttemptID: id, AllocatedAt: "2026-01-01T00:01:00Z", Evidence: []ArtifactRef{}}) + var err error + c1.Deliveries, err = AllocateDeliveryV2(start.Deliveries, scope, CheckpointSubmissionCandidate, id) + if err != nil { + t.Fatal(err) + } + _, inventory := candidateInventoryFixture(t) + inventory.Scope = scope + chain := checkpointSigned(fmt.Sprintf("%s/chain-%04d", phase, scope.Index)) + evidence := []ArtifactRef{} + for _, ref := range inventory.Files { + ref.Name = fmt.Sprintf("%s/contributions/%04d/%s", phase, scope.Index, ref.Name) + evidence = append(evidence, ref) + } + evidence = checkpointArtifacts(append(evidence, checkpointArtifact(fmt.Sprintf("%s/contributions/%04d/verification.json", phase, scope.Index), "verification"))...) + c2 := nextCheckpointV4(t, c1, CheckpointTransitionV4{Kind: accept, Scope: &scope, AttemptID: id, Record: &chain, Evidence: evidence, Contribution: &inventory}) + c2.Deliveries, err = AdvanceDeliveryV2(c1.Deliveries, id, DeliveryAccepted, &inventory) + if err != nil { + t.Fatal(err) + } + nextState := CheckpointPhaseState{Phase: phase, AcceptedCount: scope.Index, HeadRecordID: NewDigest([]byte(string(phase) + "accepted-head")).SHA256, HeadPayload: evidence[2], Chain: chain} + if phase == Phase1 { + c2.Progress.Phase1 = nextState + } else { + c2.Progress.Phase2 = &nextState + } + sequence := []CheckpointV4{start, c1, c2} + for i := 1; i < len(sequence); i++ { + if err := ValidateCheckpointTransitionV4(sequence[i-1], sequence[i]); err != nil { + t.Fatalf("%s edge %d: %v", phase, i, err) + } + } + return sequence +} + +func TestCheckpointV4FullStructuralLifecycle(t *testing.T) { + d, c, _, _ := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, c, Phase1) + c = turn[len(turn)-1] + stages := []CheckpointTransitionKind{CheckpointPhase1Closed, CheckpointPhase1BeaconRecorded, CheckpointPhase1Sealed, CheckpointPhase2Initialized, CheckpointPhase2Closed, CheckpointPhase2BeaconRecorded, CheckpointFinalCandidateRecorded, CheckpointReleaseReviewRecorded, CheckpointFinalReleaseRecorded} + for _, kind := range stages { + record := checkpointSigned("lifecycle/" + string(kind)) + evidence := []ArtifactRef{} + if kind != CheckpointPhase1Closed && kind != CheckpointPhase2Closed && kind != CheckpointReleaseReviewRecorded { + evidence = append(evidence, checkpointArtifact("lifecycle/"+string(kind)+".bin", "payload")) + } + if kind == CheckpointFinalReleaseRecorded { + record = SignedArtifactRefs{Record: checkpointArtifact(FinalReleasePackagePrefixV4+keybundle.ManifestFile, "manifest"), Signature: checkpointArtifact(FinalReleasePackagePrefixV4+keybundle.ManifestSignatureFile, "signature")} + evidence = checkpointArtifacts(checkpointArtifact(FinalReleasePackagePrefixV4+FinalTranscriptFile, "transcript"), checkpointArtifact(FinalReleasePackagePrefixV4+ReleaseChecksumsFile, "checksums"), checkpointArtifact(FinalReleasePackagePrefixV4+keybundle.ManifestPublicKeyFile, "public key")) + } + next := nextCheckpointV4(t, c, CheckpointTransitionV4{Kind: kind, Record: &record, Evidence: evidence}) + switch kind { + case CheckpointPhase1Closed: + next.Progress.Phase1Closure = &record + case CheckpointPhase1BeaconRecorded: + next.Progress.Phase1Beacon = &record + case CheckpointPhase1Sealed: + next.Progress.Phase1Seal = &record + case CheckpointPhase2Initialized: + next.Progress.Phase2 = &CheckpointPhaseState{Phase: Phase2, HeadRecordID: NewDigest([]byte("p2genesis")).SHA256, HeadPayload: evidence[0], Chain: record} + case CheckpointPhase2Closed: + next.Progress.Phase2Closure = &record + case CheckpointPhase2BeaconRecorded: + next.Progress.Phase2Beacon = &record + case CheckpointFinalCandidateRecorded: + next.Progress.FinalCandidate = &record + next.Transition.ReplayVerification = &CheckpointReplayVerificationV4{Method: CoordinatorReplayReleaseV1, ToolBinary: d.Software.ToolBinary} + for _, mutate := range []func(*CheckpointReplayVerificationV4){ + func(claim *CheckpointReplayVerificationV4) { claim.Method = "signature-only" }, + func(claim *CheckpointReplayVerificationV4) { claim.ToolBinary = Digest{} }, + } { + bad := cloneCheckpointV4(t, next) + mutate(bad.Transition.ReplayVerification) + if err := ValidateCheckpointTransitionV4(c, bad); err == nil { + t.Fatal("invalid final replay claim accepted") + } + } + bad := cloneCheckpointV4(t, next) + bad.Transition.ReplayVerification = nil + if err := ValidateCheckpointTransitionV4(c, bad); err == nil { + t.Fatal("missing final replay claim accepted") + } + case CheckpointReleaseReviewRecorded: + next.Progress.ReleaseReview = &record + lateAudit := checkpointSigned("audits/late") + lateIncident := checkpointSigned("governance/late") + for _, tx := range []CheckpointTransitionV4{ + {Kind: CheckpointAuditRecorded, Record: &lateAudit, Evidence: []ArtifactRef{}}, + {Kind: CheckpointIncidentRecorded, Record: &lateIncident, Evidence: checkpointArtifacts(checkpointArtifact("governance/late.txt", "late"))}, + } { + late := nextCheckpointV4(t, next, tx) + if err := ValidateCheckpointTransitionV4(next, late); err == nil { + t.Fatalf("%s accepted after release review", tx.Kind) + } + } + case CheckpointFinalReleaseRecorded: + next.Progress.FinalRelease = &record + } + if err := ValidateCheckpointTransitionV4(c, next); err != nil { + t.Fatalf("%s: %v", kind, err) + } + c = next + if kind == CheckpointPhase2Initialized { + turn = checkpointTurnV4(t, d, c, Phase2) + c = turn[len(turn)-1] + } + } + if c.Progress.FinalRelease == nil { + t.Fatal("did not reach final release") + } + for _, kind := range []CheckpointTransitionKind{CheckpointIncidentRecorded, CheckpointAborted, CheckpointRestarted} { + pair := checkpointSigned("governance/after-release") + tx := CheckpointTransitionV4{Kind: kind, Record: &pair, Evidence: checkpointArtifacts(checkpointArtifact("governance/statement.txt", "public"))} + if kind == CheckpointRestarted { + fresh := checkpointSigned("restart/definition") + tx.RestartDefinition = &fresh + tx.Evidence = appendCheckpointArtifacts(tx.Evidence, fresh.Record, fresh.Signature) + } + next := nextCheckpointV4(t, c, tx) + if kind != CheckpointIncidentRecorded { + next.Progress.Terminal = &CheckpointTerminalV4{Kind: governanceKindV4(kind), Record: pair, RestartDefinition: tx.RestartDefinition} + } + if err := ValidateCheckpointTransitionV4(c, next); err == nil { + t.Fatalf("%s allowed after release", kind) + } + } +} + +func TestCheckpointV4RejectsSkippedOrAlteredTurnEdges(t *testing.T) { + d, c, _, _ := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, c, Phase1) + for name, mutate := range map[string]func(*CheckpointV4){ + "wrong parent": func(n *CheckpointV4) { n.PreviousCheckpoint.Record.Digest = NewDigest([]byte("other checkpoint")) }, + "wrong sequence": func(n *CheckpointV4) { n.Sequence++ }, + "changed policy": func(n *CheckpointV4) { n.AssurancePolicy.PublicWitnessesPerPhase++ }, + "changed runtime claim": func(n *CheckpointV4) { n.ReleaseVerification = "none" }, + "hidden extra file": func(n *CheckpointV4) { + n.AcceptedArtifacts = appendCheckpointArtifacts(n.AcceptedArtifacts, checkpointArtifact("unexpected.json", "extra")) + }, + "wrong slot": func(n *CheckpointV4) { n.Transition.AttemptID = strings.Repeat("e", 32) }, + "skipped count": func(n *CheckpointV4) { n.Progress.Phase1.AcceptedCount++ }, + "changed result": func(n *CheckpointV4) { n.Transition.Contribution.Files[0].Digest = NewDigest([]byte("changed")) }, + "extra candidate file": func(n *CheckpointV4) { + n.Transition.Contribution.Files = append(n.Transition.Contribution.Files, checkpointArtifact("extra.json", "extra")) + }, + "missing verification": func(n *CheckpointV4) { + for i, ref := range n.Transition.Evidence { + if strings.HasSuffix(ref.Name, "verification.json") { + n.Transition.Evidence = append(n.Transition.Evidence[:i], n.Transition.Evidence[i+1:]...) + break + } + } + }, + "discard history": func(n *CheckpointV4) { n.Deliveries = n.Deliveries[1:] }, + "advance another phase": func(n *CheckpointV4) { n.Progress.Phase2 = &n.Progress.Phase1 }, + } { + t.Run(name, func(t *testing.T) { + n := cloneCheckpointV4(t, turn[2]) + mutate(&n) + if err := ValidateCheckpointTransitionV4(turn[1], n); err == nil { + t.Fatal("invalid edge accepted") + } + }) + } + if err := ValidateCheckpointTransitionV4(turn[0], turn[2]); err == nil { + t.Fatal("allocation step skipped") + } + n := cloneCheckpointV4(t, turn[2]) + n.Sequence = turn[0].Sequence + 1 + raw, _ := MarshalCanonical(turn[0]) + n.PreviousCheckpoint.Record.Digest = NewDigest(raw) + if err := ValidateCheckpointTransitionV4(turn[0], n); err == nil { + t.Fatal("candidate accepted before allocation") + } +} + +func TestCheckpointV4AuthenticatesExactPolicyAndRejectsLegacy(t *testing.T) { + d, c, db, ds := checkpointFixtureV4(t) + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{1}, 32)) + cb, cs, err := SignRecord(c, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + if _, err := VerifySignedCheckpointV4(d, db, ds, cb, cs); err != nil { + t.Fatal(err) + } + if _, err := VerifySignedCheckpoint(d, db, ds, cb, cs); err == nil { + t.Fatal("old checkpoint verifier accepted v4") + } + old := adversarialDefinition(t) + odb, ods, err := SignRecord(old, old.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + if _, err := VerifySignedCheckpointV4(old, odb, ods, cb, cs); err == nil { + t.Fatal("v4 checker accepted old definition") + } + changed := cloneCheckpointV4(t, c) + changed.AssurancePolicy.PublicWitnessesPerPhase++ + bad, bads, err := SignRecord(changed, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + if _, err := VerifySignedCheckpointV4(d, db, ds, bad, bads); err == nil { + t.Fatal("signed but changed policy accepted") + } + wrongKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{2}, 32)) + bad, bads, err = SignRecord(c, d.Coordinator.KeyID, wrongKey) + if err != nil { + t.Fatal(err) + } + if _, err := VerifySignedCheckpointV4(d, db, ds, bad, bads); err == nil { + t.Fatal("wrong coordinator key accepted") + } + for _, field := range []string{"relay_release_id", "manifest_key", "acknowledgement"} { + if bytes.Contains(cb, []byte(field)) { + t.Fatalf("transport field %s leaked into v4", field) + } + } +} + +func TestCheckpointV4RejectsOverlappingArtifactNames(t *testing.T) { + refs := checkpointArtifacts(checkpointArtifact("a", "file"), checkpointArtifact("a-b", "middle"), checkpointArtifact("a/b", "child")) + if err := validateV4ArtifactSet(refs, 10); err == nil { + t.Fatal("non-adjacent file/directory collision accepted") + } +} + +func TestCheckpointV4DeliveryRetryAndRejectionEdges(t *testing.T) { + d, initial, _, _ := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, initial, Phase1) + previous := turn[1] + for _, kind := range []CheckpointTransitionKind{CheckpointDeliveryRetired, CheckpointContributionRejected} { + t.Run(string(kind), func(t *testing.T) { + transition := CheckpointTransitionV4{Kind: kind, Scope: turn[2].Transition.Scope, AttemptID: turn[2].Transition.AttemptID, NextAttemptID: strings.Repeat("e", 32), Evidence: []ArtifactRef{}} + status := DeliveryRetired + if kind == CheckpointContributionRejected { + status = DeliveryRejected + transition.Contribution = turn[2].Transition.Contribution + } + next := nextCheckpointV4(t, previous, transition) + var err error + next.Deliveries, err = AdvanceDeliveryV2(previous.Deliveries, transition.AttemptID, status, transition.Contribution) + if err != nil { + t.Fatal(err) + } + next.Deliveries, err = AllocateDeliveryV2(next.Deliveries, *transition.Scope, CheckpointSubmissionCandidate, transition.NextAttemptID) + if err != nil { + t.Fatal(err) + } + if err := ValidateCheckpointTransitionV4(previous, next); err != nil { + t.Fatal(err) + } + changed := cloneCheckpointV4(t, next) + changed.Deliveries[1].Status = DeliveryRetired + changed.Deliveries[1].ContributionResultID = "" + if kind == CheckpointContributionRejected { + if err := ValidateCheckpointTransitionV4(previous, changed); err == nil { + t.Fatal("rejection silently retired") + } + } + accept := turn[2].Transition + accept.AttemptID = transition.NextAttemptID + final := nextCheckpointV4(t, next, accept) + final.Progress = turn[2].Progress + final.Deliveries, err = AdvanceDeliveryV2(next.Deliveries, accept.AttemptID, DeliveryAccepted, accept.Contribution) + if kind == CheckpointContributionRejected { + if err == nil { + t.Fatal("rejected result accepted through replacement") + } + final.Deliveries = append([]DeliverySlotV2{}, next.Deliveries...) + last := len(final.Deliveries) - 1 + final.Deliveries[last].Status = DeliveryAccepted + final.Deliveries[last].ContributionResultID, _ = accept.Contribution.ID() + if err := ValidateCheckpointTransitionV4(next, final); err == nil { + t.Fatal("hand-constructed rejection bypass accepted") + } + } else { + if err != nil { + t.Fatal(err) + } + if err := ValidateCheckpointTransitionV4(next, final); err != nil { + t.Fatal(err) + } + } + // Rejected payload hashes may be public state, but their unaccepted + // bytes must never enter the public accepted-artifact inventory. + bad := cloneCheckpointV4(t, next) + bad.AcceptedArtifacts = appendCheckpointArtifacts(bad.AcceptedArtifacts, checkpointArtifact("rejected.bin", "unaccepted")) + if err := ValidateCheckpointTransitionV4(previous, bad); err == nil { + t.Fatal("rejected payload published as accepted") + } + }) + } +} + +func TestCheckpointV4RetirementAtLimitCanCloseAfterMinimum(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, initial, Phase1) + previous := turn[2] + scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: Phase1, Index: 2, ParticipantID: d.Phase1Policy.Participants[1], ParentHeadID: previous.Progress.Phase1.HeadRecordID} + id := fmt.Sprintf("%032x", 200) + c := nextCheckpointV4(t, previous, CheckpointTransitionV4{Kind: CheckpointPhase1CandidateAllocated, Scope: &scope, AttemptID: id, AllocatedAt: "2026-01-01T00:02:00Z", Evidence: []ArtifactRef{}}) + var err error + c.Deliveries, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionCandidate, id) + if err != nil { + t.Fatal(err) + } + if err := ValidateCheckpointTransitionV4(previous, c); err != nil { + t.Fatal(err) + } + for i := 0; i < MaxDeliveryAttemptsPerSubmissionV2; i++ { + tx := CheckpointTransitionV4{Kind: CheckpointDeliveryRetired, Scope: &scope, AttemptID: id, Evidence: []ArtifactRef{}} + n := nextCheckpointV4(t, c, tx) + n.Deliveries, err = AdvanceDeliveryV2(c.Deliveries, id, DeliveryRetired, nil) + if err != nil { + t.Fatal(err) + } + if err := ValidateCheckpointTransitionV4(c, n); err != nil { + t.Fatal(err) + } + c = n + if i < MaxDeliveryAttemptsPerSubmissionV2-1 { + nextID := fmt.Sprintf("%032x", 201+i) + n = nextCheckpointV4(t, c, CheckpointTransitionV4{Kind: CheckpointDeliveryReallocated, Scope: &scope, AttemptID: id, NextAttemptID: nextID, Evidence: []ArtifactRef{}}) + n.Deliveries, err = AllocateDeliveryV2(c.Deliveries, scope, CheckpointSubmissionCandidate, nextID) + if err != nil { + t.Fatal(err) + } + if err := ValidateCheckpointTransitionV4(c, n); err != nil { + t.Fatal(err) + } + c = n + id = nextID + } + } + if _, err := AllocateDeliveryV2(c.Deliveries, scope, CheckpointSubmissionCandidate, strings.Repeat("f", 32)); err == nil { + t.Fatal("attempt budget exceeded") + } + closure := checkpointSigned("phase1/closure") + n := nextCheckpointV4(t, c, CheckpointTransitionV4{Kind: CheckpointPhase1Closed, Record: &closure, Evidence: []ArtifactRef{}}) + n.Progress.Phase1Closure = &closure + if err := ValidateCheckpointTransitionV4(c, n); err != nil { + t.Fatal(err) + } + key := adversarialPrivateKey(1) + pb, ps, err := SignRecord(c, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + n.PreviousCheckpoint.Signature.Digest = NewDigest(ps) + nb, ns, err := SignRecord(n, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyCheckpointEdgeV4(d, db, ds, pb, ps, nb, ns); err != nil { + t.Fatal(err) + } +} + +func TestCheckpointV4ExactPredecessorSignatureAndMinimum(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + turn := checkpointTurnV4(t, d, initial, Phase1) + next := turn[1] + key := adversarialPrivateKey(1) + pb, ps, err := SignRecord(initial, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + next.PreviousCheckpoint.Signature.Digest = NewDigest(ps) + nb, ns, err := SignRecord(next, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyCheckpointEdgeV4(d, db, ds, pb, ps, nb, ns); err != nil { + t.Fatal(err) + } + next.PreviousCheckpoint.Signature.Digest = NewDigest([]byte("wrong predecessor signature")) + nb, ns, err = SignRecord(next, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyCheckpointEdgeV4(d, db, ds, pb, ps, nb, ns); err == nil { + t.Fatal("wrong signature reference accepted") + } + closure := checkpointSigned("phase1/closure") + closed := nextCheckpointV4(t, initial, CheckpointTransitionV4{Kind: CheckpointPhase1Closed, Record: &closure, Evidence: []ArtifactRef{}}) + closed.Progress.Phase1Closure = &closure + closed.PreviousCheckpoint.Signature.Digest = NewDigest(ps) + nb, ns, err = SignRecord(closed, d.Coordinator.KeyID, key) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyCheckpointEdgeV4(d, db, ds, pb, ps, nb, ns); err == nil { + t.Fatal("phase closed before signed contribution minimum") + } +} + +func TestAppendUniqueSortedArtifactsV4KeepsEmptyListExplicit(t *testing.T) { + artifacts := appendUniqueSortedArtifactsV4(nil) + if artifacts == nil || len(artifacts) != 0 { + t.Fatalf("empty artifact list = %#v, want explicit empty list", artifacts) + } +} diff --git a/internal/mpcceremony/checkpoint_v4_turn.go b/internal/mpcceremony/checkpoint_v4_turn.go new file mode 100644 index 00000000..73041df2 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_turn.go @@ -0,0 +1,444 @@ +package mpcceremony + +import ( + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "sort" + + "golang.org/x/crypto/blake2b" +) + +// CandidateAllocationCheckpointV4Options identifies one fresh coordinator +// allocation. The phase, participant, index and parent head are derived from +// authenticated ceremony state rather than supplied by the transport layer. +type CandidateAllocationCheckpointV4Options struct { + Trust TrustPaths + ArtifactRoot string + Checkpoint SignedArtifactRefs + AttemptID string + AllocatedAt string +} + +// CandidateAllocationCheckpointV4 is an internally prepared, unsigned +// checkpoint. The caller must sign its Canonical bytes with the authenticated +// coordinator key and publish the record/signature pair atomically. +type CandidateAllocationCheckpointV4 struct { + Checkpoint CheckpointV4 + Scope ContributionScope + Canonical []byte +} + +// PrepareCandidateAllocationCheckpointV4 derives and verifies the exact next +// contribution turn. It never accepts caller-supplied phase, index, +// participant or parent-head values. +func PrepareCandidateAllocationCheckpointV4(options CandidateAllocationCheckpointV4Options) (CandidateAllocationCheckpointV4, error) { + if err := validateHex(options.AttemptID, 16); err != nil { + return CandidateAllocationCheckpointV4{}, fmt.Errorf("attempt ID: %w", err) + } + if err := validateTimestamp("allocated_at", options.AllocatedAt); err != nil { + return CandidateAllocationCheckpointV4{}, err + } + stored, err := openStoredCheckpointV4(options.Trust, options.ArtifactRoot, options.Checkpoint) + if err != nil { + return CandidateAllocationCheckpointV4{}, err + } + if stored.trusted.Definition.Schema != DefinitionSchemaV4 { + _ = stored.reader.root.Close() + return CandidateAllocationCheckpointV4{}, errors.New("candidate allocation requires definition v4") + } + previous := stored.ancestry.head + d := stored.trusted.Definition + if err := stored.reader.root.Close(); err != nil { + return CandidateAllocationCheckpointV4{}, err + } + + phase := Phase1 + state := previous.Progress.Phase1 + policy := d.Phase1Policy + if previous.Progress.Phase1Closure != nil { + if previous.Progress.Phase2 == nil || previous.Progress.Phase2Closure != nil { + return CandidateAllocationCheckpointV4{}, errors.New("ceremony is not accepting contribution allocations") + } + phase = Phase2 + state = *previous.Progress.Phase2 + policy = d.Phase2Policy + } + index := int(state.AcceptedCount) + 1 + if index > len(policy.Participants) { + return CandidateAllocationCheckpointV4{}, errors.New("signed participant schedule has no next contribution") + } + scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: phase, Index: uint8(index), ParticipantID: policy.Participants[index-1], ParentHeadID: state.HeadRecordID} + if err := scope.ValidateAssignment(d); err != nil { + return CandidateAllocationCheckpointV4{}, err + } + + next, err := cloneCheckpointForTurnV4(previous) + if err != nil { + return CandidateAllocationCheckpointV4{}, err + } + next.PreviousCheckpoint = &options.Checkpoint + next.Sequence++ + kind := CheckpointPhase1CandidateAllocated + if phase == Phase2 { + kind = CheckpointPhase2CandidateAllocated + } + next.Transition = CheckpointTransitionV4{Kind: kind, Scope: &scope, AttemptID: options.AttemptID, AllocatedAt: options.AllocatedAt, Evidence: []ArtifactRef{}} + next.Deliveries, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionCandidate, options.AttemptID) + if err != nil { + return CandidateAllocationCheckpointV4{}, err + } + canonical, err := PrepareCheckpointV4(CheckpointPreparationV4{Trust: options.Trust, ArtifactRoot: options.ArtifactRoot, Proposal: next}) + if err != nil { + return CandidateAllocationCheckpointV4{}, err + } + return CandidateAllocationCheckpointV4{Checkpoint: next, Scope: scope, Canonical: canonical}, nil +} + +type AcceptAllocatedCandidateV4Options struct { + Trust TrustPaths + Circuit *CompiledCircuit + ArtifactRoot string + Checkpoint SignedArtifactRefs + AttemptID string + CandidateDir string + CoordinatorPrivateKeyPath string + AcceptedAt string +} + +type AcceptedCandidateCheckpointV4 struct { + Checkpoint CheckpointV4 + Scope ContributionScope + Candidate CandidateInventory + Accepted AcceptContributionFilesResult + Canonical []byte +} + +// RejectAllocatedCandidateV4Options identifies one active allocation whose +// complete candidate bytes are being retained as rejected. Unlike acceptance, +// rejection intentionally does not parse or validate the candidate's records, +// signatures, cleanup claim, or contribution mathematics: it commits only the +// exact five private bytes that were rejected. +type RejectAllocatedCandidateV4Options struct { + Trust TrustPaths + ArtifactRoot string + Checkpoint SignedArtifactRefs + AttemptID string + RejectedCandidateDir string +} + +// RejectedCandidateCheckpointV4 is an internally prepared unsigned rejection +// checkpoint. The caller signs Canonical with the authenticated coordinator +// key and publishes the pair atomically. +type RejectedCandidateCheckpointV4 struct { + Checkpoint CheckpointV4 + Scope ContributionScope + Candidate CandidateInventory + Canonical []byte +} + +// RejectAllocatedCandidateV4 derives a rejection from the authenticated active +// allocation. It never accepts caller-supplied phase, index, participant, or +// parent-head values, and never creates a replacement allocation. +func RejectAllocatedCandidateV4(options RejectAllocatedCandidateV4Options) (RejectedCandidateCheckpointV4, error) { + if err := validateHex(options.AttemptID, 16); err != nil { + return RejectedCandidateCheckpointV4{}, fmt.Errorf("attempt ID: %w", err) + } + stored, err := openStoredCheckpointV4(options.Trust, options.ArtifactRoot, options.Checkpoint) + if err != nil { + return RejectedCandidateCheckpointV4{}, err + } + if stored.trusted.Definition.Schema != DefinitionSchemaV4 { + _ = stored.reader.root.Close() + return RejectedCandidateCheckpointV4{}, errors.New("candidate rejection requires definition v4") + } + allocation, ok := stored.ancestry.allocations[options.AttemptID] + if !ok || allocation.Scope == nil { + _ = stored.reader.root.Close() + return RejectedCandidateCheckpointV4{}, errors.New("candidate attempt is not allocated by the authenticated checkpoint ancestry") + } + previous := stored.ancestry.head + scope := *allocation.Scope + active := false + for _, slot := range previous.Deliveries { + if slot.AttemptID == options.AttemptID && slot.Kind == CheckpointSubmissionCandidate && slot.Status == DeliveryAllocated && slot.Scope == scope { + active = true + } + } + if !active { + _ = stored.reader.root.Close() + return RejectedCandidateCheckpointV4{}, errors.New("candidate allocation is no longer active at the authenticated checkpoint") + } + if err := previous.Progress.currentTurn(scope); err != nil { + _ = stored.reader.root.Close() + return RejectedCandidateCheckpointV4{}, err + } + if err := stored.reader.root.Close(); err != nil { + return RejectedCandidateCheckpointV4{}, err + } + + inventory, err := rejectedCandidateInventoryV4(options.RejectedCandidateDir, scope) + if err != nil { + return RejectedCandidateCheckpointV4{}, err + } + next, err := cloneCheckpointForTurnV4(previous) + if err != nil { + return RejectedCandidateCheckpointV4{}, err + } + next.PreviousCheckpoint = &options.Checkpoint + next.Sequence++ + next.Transition = CheckpointTransitionV4{Kind: CheckpointContributionRejected, Scope: &scope, AttemptID: options.AttemptID, Contribution: &inventory, Evidence: []ArtifactRef{}} + next.Deliveries, err = AdvanceDeliveryV2(previous.Deliveries, options.AttemptID, DeliveryRejected, &inventory) + if err != nil { + return RejectedCandidateCheckpointV4{}, err + } + canonical, err := PrepareCheckpointV4(CheckpointPreparationV4{Trust: options.Trust, ArtifactRoot: options.ArtifactRoot, Proposal: next, RejectedCandidateDir: options.RejectedCandidateDir}) + if err != nil { + return RejectedCandidateCheckpointV4{}, err + } + return RejectedCandidateCheckpointV4{Checkpoint: next, Scope: scope, Candidate: inventory, Canonical: canonical}, nil +} + +// rejectedCandidateInventoryV4 hashes exactly the fixed candidate filenames +// without interpreting their contents. That lets an invalid candidate be +// retained and identified safely without treating an unverified attestation or +// cleanup claim as valid evidence. +func rejectedCandidateInventoryV4(dir string, scope ContributionScope) (CandidateInventory, error) { + reader, err := openCheckpointReaderV4(dir) + if err != nil { + return CandidateInventory{}, err + } + defer func() { _ = reader.root.Close() }() + expected := []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} + entries, err := reader.root.Open(".") + if err != nil { + return CandidateInventory{}, err + } + names, err := entries.Readdirnames(len(expected) + 1) + _ = entries.Close() + if err != nil && !errors.Is(err, io.EOF) { + return CandidateInventory{}, err + } + if len(names) != len(expected) { + return CandidateInventory{}, errors.New("rejected directory does not contain the exact complete candidate inventory") + } + files := make([]ArtifactRef, 0, len(expected)) + for _, name := range expected { + if !slices.Contains(names, name) { + return CandidateInventory{}, errors.New("rejected directory has missing or extra candidate files") + } + limit := int64(maxSignedRecordBytes) + switch name { + case "contribution.bin": + limit = MaxArtifactSize + case "attestation.sig", "erasure.sig": + limit = 4096 + } + ref, err := rejectedCandidateFileRefV4(reader, name, limit) + if err != nil { + return CandidateInventory{}, err + } + files = append(files, ref) + } + inventory := CandidateInventory{Schema: CandidateInventorySchemaV1, Scope: scope, Files: files} + if err := inventory.Validate(); err != nil { + return CandidateInventory{}, err + } + return inventory, nil +} + +func rejectedCandidateFileRefV4(reader *checkpointReaderV4, name string, limit int64) (ArtifactRef, error) { + before, err := reader.root.Lstat(name) + if err != nil { + return ArtifactRef{}, err + } + if !before.Mode().IsRegular() || before.Size() <= 0 || before.Size() > limit { + return ArtifactRef{}, errors.New("rejected candidate file must be a bounded regular file") + } + f, err := reader.root.Open(name) + if err != nil { + return ArtifactRef{}, err + } + defer f.Close() + opened, err := f.Stat() + if err != nil { + return ArtifactRef{}, err + } + if !os.SameFile(before, opened) || opened.Size() != before.Size() { + return ArtifactRef{}, errors.New("rejected candidate file changed while opening") + } + sha := sha256.New() + blake, err := blake2b.New256(nil) + if err != nil { + return ArtifactRef{}, err + } + size, err := io.Copy(io.MultiWriter(sha, blake), io.LimitReader(f, limit+1)) + if err != nil { + return ArtifactRef{}, err + } + after, err := f.Stat() + if err != nil { + return ArtifactRef{}, err + } + if size != before.Size() || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) { + return ArtifactRef{}, errors.New("rejected candidate file changed while reading") + } + return ArtifactRef{Name: name, Digest: Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size}}, nil +} + +// VerifyAndAcceptAllocatedCandidateV4 authenticates the allocation, verifies +// the candidate mathematics, publishes immutable accepted artifacts, and +// prepares the exact next checkpoint. No phase, participant, index, or input +// chain path is accepted from the caller. +func VerifyAndAcceptAllocatedCandidateV4(options AcceptAllocatedCandidateV4Options) (AcceptedCandidateCheckpointV4, error) { + if err := validateHex(options.AttemptID, 16); err != nil { + return AcceptedCandidateCheckpointV4{}, fmt.Errorf("attempt ID: %w", err) + } + stored, err := openStoredCheckpointV4(options.Trust, options.ArtifactRoot, options.Checkpoint) + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + if stored.trusted.Definition.Schema != DefinitionSchemaV4 { + _ = stored.reader.root.Close() + return AcceptedCandidateCheckpointV4{}, errors.New("candidate acceptance requires definition v4") + } + allocation, ok := stored.ancestry.allocations[options.AttemptID] + if !ok || allocation.Scope == nil { + _ = stored.reader.root.Close() + return AcceptedCandidateCheckpointV4{}, errors.New("candidate attempt is not allocated by the authenticated checkpoint ancestry") + } + previous := stored.ancestry.head + scope := *allocation.Scope + active := false + for _, slot := range previous.Deliveries { + if slot.AttemptID == options.AttemptID && slot.Kind == CheckpointSubmissionCandidate && slot.Status == DeliveryAllocated && slot.Scope == scope { + active = true + } + } + if !active { + _ = stored.reader.root.Close() + return AcceptedCandidateCheckpointV4{}, errors.New("candidate allocation is no longer active at the authenticated checkpoint") + } + if err := previous.Progress.currentTurn(scope); err != nil { + _ = stored.reader.root.Close() + return AcceptedCandidateCheckpointV4{}, err + } + if err := stored.reader.root.Close(); err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + + state := previous.Progress.Phase1 + if scope.Phase == Phase2 { + state = *previous.Progress.Phase2 + } + paths := PhaseTranscriptPaths{RootDir: options.ArtifactRoot, ChainPath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(state.Chain.Record.Name)), ChainSignaturePath: filepath.Join(options.ArtifactRoot, filepath.FromSlash(state.Chain.Signature.Name))} + // Authenticate the complete fixed inventory before mathematical replay. + // This is also the boundary that distinguishes stable invalid candidate + // bytes from missing, changing, or otherwise operationally uncertain files. + if _, err := InspectContributionInventoryV4(options.Trust, paths, scope, options.CandidateDir); err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + accept := AcceptContributionFilesOptions{Trust: options.Trust, Circuit: options.Circuit, Phase: scope.Phase, Transcript: paths, CandidateDir: options.CandidateDir, CoordinatorPrivateKeyPath: options.CoordinatorPrivateKeyPath, AcceptedAt: options.AcceptedAt, ClassifyCandidateInvalid: true} + if scope.Phase == Phase2 { + if previous.Progress.Phase1Seal == nil { + return AcceptedCandidateCheckpointV4{}, errors.New("phase2 acceptance requires the authenticated phase1 seal") + } + accept.Phase1SealPath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Progress.Phase1Seal.Record.Name)) + accept.Phase1SealSignaturePath = filepath.Join(options.ArtifactRoot, filepath.FromSlash(previous.Progress.Phase1Seal.Signature.Name)) + } + accepted, err := VerifyAndAcceptContribution(accept) + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + acceptedPaths := PhaseTranscriptPaths{RootDir: options.ArtifactRoot, ChainPath: accepted.ChainPath, ChainSignaturePath: accepted.ChainSignaturePath} + var chain Chain + var chainRefs SignedArtifactRefs + if scope.Phase == Phase1 { + chain, chainRefs, err = VerifyAcceptedPhase1Chain(options.Trust, options.Circuit, acceptedPaths) + } else { + chain, chainRefs, err = VerifyAcceptedPhase2Chain(options.Trust, options.Circuit, options.ArtifactRoot, accept.Phase1SealPath, accept.Phase1SealSignaturePath, acceptedPaths) + } + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + last := chain.Records[len(chain.Records)-1] + files := []ArtifactRef{last.Attestation, last.AttestationSignature, last.OutputPayload, last.Erasure, last.ErasureSignature} + inventory := CandidateInventory{Schema: CandidateInventorySchemaV1, Scope: scope, Files: slices.Clone(files)} + for i := range inventory.Files { + inventory.Files[i].Name = filepath.Base(inventory.Files[i].Name) + } + if err := inventory.Validate(); err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + evidence := append(slices.Clone(files), last.Verification) + sort.Slice(evidence, func(i, j int) bool { return evidence[i].Name < evidence[j].Name }) + + next, err := cloneCheckpointForTurnV4(previous) + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + next.PreviousCheckpoint = &options.Checkpoint + next.Sequence++ + kind := CheckpointPhase1CandidateAccepted + if scope.Phase == Phase2 { + kind = CheckpointPhase2CandidateAccepted + } + next.Transition = CheckpointTransitionV4{Kind: kind, Scope: &scope, AttemptID: options.AttemptID, Record: &chainRefs, Evidence: evidence, Contribution: &inventory} + next.Deliveries, err = AdvanceDeliveryV2(previous.Deliveries, options.AttemptID, DeliveryAccepted, &inventory) + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + head, err := chain.HeadRecordID() + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + payload, err := chain.HeadPayload() + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + nextState := CheckpointPhaseState{Phase: scope.Phase, AcceptedCount: scope.Index, HeadRecordID: head, HeadPayload: payload, Chain: chainRefs} + if scope.Phase == Phase1 { + next.Progress.Phase1 = nextState + } else { + next.Progress.Phase2 = &nextState + } + next.AcceptedArtifacts = appendUniqueSortedArtifactsV4(previous.AcceptedArtifacts, append(signedArtifacts(&chainRefs), evidence...)...) + canonical, err := PrepareCheckpointV4(CheckpointPreparationV4{Trust: options.Trust, ArtifactRoot: options.ArtifactRoot, Proposal: next, Circuit: options.Circuit}) + if err != nil { + return AcceptedCandidateCheckpointV4{}, err + } + return AcceptedCandidateCheckpointV4{Checkpoint: next, Scope: scope, Candidate: inventory, Accepted: accepted, Canonical: canonical}, nil +} + +func cloneCheckpointForTurnV4(value CheckpointV4) (CheckpointV4, error) { + data, err := MarshalCanonical(value) + if err != nil { + return CheckpointV4{}, err + } + var cloned CheckpointV4 + if err := UnmarshalCanonical(data, &cloned); err != nil { + return CheckpointV4{}, err + } + return cloned, nil +} + +func appendUniqueSortedArtifactsV4(base []ArtifactRef, values ...ArtifactRef) []ArtifactRef { + result := slices.Clone(base) + if result == nil { + // V4 distinguishes an explicit empty artifact set from a missing/null + // set. Some lifecycle records have no auxiliary evidence, but they must + // still encode evidence as [] rather than omitting the list. + result = []ArtifactRef{} + } + for _, value := range values { + if !slices.Contains(result, value) { + result = append(result, value) + } + } + sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) + return result +} diff --git a/internal/mpcceremony/computation_output_v4.go b/internal/mpcceremony/computation_output_v4.go new file mode 100644 index 00000000..fad3713d --- /dev/null +++ b/internal/mpcceremony/computation_output_v4.go @@ -0,0 +1,101 @@ +package mpcceremony + +import "errors" + +// ComputationOutputInspectionV4 is the three-file result before cleanup signing. +// It is not a CandidateInventory and deliberately has no candidate result ID. +// It proves signatures and bytes, not mathematics, process exit or cleanup. +type ComputationOutputInspectionV4 struct { + Scope ContributionScope `json:"scope"` + Predecessor SignedArtifactRefs `json:"predecessor"` + Files []ArtifactRef `json:"files"` +} + +func InspectComputationOutputV4(trust TrustPaths, predecessor PhaseTranscriptPaths, expected ContributionScope, candidateDir string) (ComputationOutputInspectionV4, error) { + var zero ComputationOutputInspectionV4 + trusted, err := LoadSignedDefinition(trust) + if err != nil { + return zero, err + } + d := trusted.Definition + if d.Schema != DefinitionSchemaV4 { + return zero, errors.New("computation output inspection requires definition v4") + } + if err := expected.ValidateAssignment(d); err != nil { + return zero, err + } + chain, refs, err := LoadSignedChainExact(trusted, predecessor) + if err != nil { + return zero, err + } + head, err := chain.HeadRecordID() + if err != nil { + return zero, err + } + if chain.Phase != expected.Phase || len(chain.Records)+1 != int(expected.Index) || head != expected.ParentHeadID { + return zero, errors.New("signed predecessor differs from the exact expected turn") + } + r, err := openCheckpointReaderV4(candidateDir) + if err != nil { + return zero, err + } + defer func() { _ = r.root.Close() }() + result, _, err := inspectComputationOutputV4(r, d, chain, expected) + if err != nil { + return zero, err + } + result.Predecessor = refs + return result, nil +} + +func inspectComputationOutputV4(r *checkpointReaderV4, d CeremonyDefinition, chain Chain, scope ContributionScope) (ComputationOutputInspectionV4, ContributionAttestation, error) { + var zero ComputationOutputInspectionV4 + var attestation ContributionAttestation + record, err := readLocalInventoryRecordV4(r, "attestation.json", maxSignedRecordBytes) + if err != nil { + return zero, attestation, err + } + signature, err := readLocalInventoryRecordV4(r, "attestation.sig", 4096) + if err != nil { + return zero, attestation, err + } + participant, ok := d.ParticipantByID(scope.ParticipantID) + if !ok { + return zero, attestation, candidateInvalid(errors.New("candidate participant is not scheduled")) + } + key, err := identityPublicKey(participant.Identity) + if err != nil { + return zero, attestation, err + } + if err := VerifySignedRecord(record, signature, &attestation, participant.Identity.KeyID, key); err != nil { + return zero, attestation, candidateInvalid(err) + } + previous, err := chain.HeadPayload() + if err != nil { + return zero, attestation, err + } + if attestation.CeremonyID != scope.CeremonyID || attestation.Phase != scope.Phase || attestation.PhaseID != chain.PhaseID || attestation.Index != scope.Index || attestation.ParticipantID != scope.ParticipantID || attestation.ParticipantKeyID != participant.Identity.KeyID || attestation.PreviousAcceptanceID != scope.ParentHeadID || attestation.PreviousPayload != previous || attestation.OutputPayload.Name != contributionLogicalNames(scope.Phase, int(scope.Index)).Payload { + return zero, attestation, candidateInvalid(errors.New("candidate attestation differs from the exact expected predecessor and participant")) + } + if err := validateAttestationSoftwareBinding(d, attestation); err != nil { + return zero, attestation, candidateInvalid(err) + } + if err := validateContributionChronology(d, chain, attestation); err != nil { + return zero, attestation, candidateInvalid(err) + } + output := attestation.OutputPayload + output.Name = "contribution.bin" + if err := output.Validate(); err != nil { + return zero, attestation, candidateInvalid(err) + } + if err := validatePortableStorageName(output.Name); err != nil { + return zero, attestation, candidateInvalid(err) + } + if _, err := r.read(output, MaxArtifactSize, false); err != nil { + if isCandidateArtifactDigestMismatch(err) { + return zero, attestation, candidateInvalid(err) + } + return zero, attestation, err + } + return ComputationOutputInspectionV4{Scope: scope, Files: []ArtifactRef{{Name: "attestation.json", Digest: NewDigest(record)}, {Name: "attestation.sig", Digest: NewDigest(signature)}, output}}, attestation, nil +} diff --git a/internal/mpcceremony/computation_output_v4_test.go b/internal/mpcceremony/computation_output_v4_test.go new file mode 100644 index 00000000..f3f12a2d --- /dev/null +++ b/internal/mpcceremony/computation_output_v4_test.go @@ -0,0 +1,73 @@ +package mpcceremony + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +func TestComputationOutputV4BeforeCleanup(t *testing.T) { + for _, phase := range []Phase{Phase1, Phase2} { + t.Run(string(phase), func(t *testing.T) { + f := localInventoryFixtureV4(t, phase) + for _, name := range []string{"erasure.json", "erasure.sig"} { + if err := os.Remove(filepath.Join(f.dir, name)); err != nil { + t.Fatal(err) + } + } + got, err := InspectComputationOutputV4(f.trust, f.paths, f.scope, f.dir) + if err != nil { + t.Fatal(err) + } + if got.Scope != f.scope || len(got.Files) != 3 || got.Files[0].Name != "attestation.json" || got.Files[1].Name != "attestation.sig" || got.Files[2].Name != "contribution.bin" || got.Predecessor.Record.Name == "" { + t.Fatalf("wrong preliminary result: %+v", got) + } + if _, err := f.inspect(); err == nil { + t.Fatal("three files became a cleanup-complete inventory") + } + // This command deliberately ignores later-stage artifacts, and never + // makes a cleanup claim even if malformed cleanup bytes are present. + putCheckpointTestFileV4(t, f.dir, "erasure.json", []byte("partial cleanup")) + again, err := InspectComputationOutputV4(f.trust, f.paths, f.scope, f.dir) + if err != nil || !reflect.DeepEqual(got, again) { + t.Fatal("later-stage files affected preliminary inspection", err) + } + }) + } +} + +func TestComputationOutputV4RejectsChangedFilesAndScope(t *testing.T) { + for _, mutation := range []string{"payload", "signature", "scope", "predecessor", "software", "symlink"} { + t.Run(mutation, func(t *testing.T) { + f := localInventoryFixtureV4(t, Phase1) + switch mutation { + case "payload": + putCheckpointTestFileV4(t, f.dir, "contribution.bin", []byte("different payload")) + case "signature": + putCheckpointTestFileV4(t, f.dir, "attestation.sig", []byte("bad signature")) + case "scope": + f.scope.Index++ + case "predecessor": + f.scope.ParentHeadID = NewDigest([]byte("wrong parent")).SHA256 + case "software": + a := f.a + a.SourceCommit = strings.Repeat("e", 40) + f.sign(t, a) + case "symlink": + path := filepath.Join(f.dir, "contribution.bin") + if err := os.Rename(path, path+".original"); err != nil { + t.Fatal(err) + } + if err := os.Symlink(path+".original", path); err != nil { + t.Fatal(err) + } + } + got, err := InspectComputationOutputV4(f.trust, f.paths, f.scope, f.dir) + if err == nil || !reflect.DeepEqual(got, ComputationOutputInspectionV4{}) { + t.Fatal("accepted invalid output or exposed partial facts", err) + } + }) + } +} diff --git a/internal/mpcceremony/contribution_allocation_v4.go b/internal/mpcceremony/contribution_allocation_v4.go new file mode 100644 index 00000000..20556f6e --- /dev/null +++ b/internal/mpcceremony/contribution_allocation_v4.go @@ -0,0 +1,84 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path/filepath" +) + +// AllocatedContributionFilesV4Options names one coordinator-allocated V4 +// attempt. The signed checkpoint, rather than caller-provided chain paths, +// selects the exact immutable input snapshot. +type AllocatedContributionFilesV4Options struct { + Trust TrustPaths + Circuit *CompiledCircuit + ArtifactRoot string + Checkpoint SignedArtifactRefs + AttemptID string + ParticipantPrivateKeyPath string + Environment ContributionEnvironment + ContributedAt string + CandidateDir string +} + +// CreateAllocatedContributionCandidateV4 authenticates the allocation and +// its exact transcript snapshot in the same process that later generates the +// contribution randomness. No candidate bytes are written before these +// checks and the full chain replay succeed. +func CreateAllocatedContributionCandidateV4(options AllocatedContributionFilesV4Options) (ContributionFilesResult, error) { + if err := validateHex(options.AttemptID, 16); err != nil { + return ContributionFilesResult{}, err + } + stored, err := openStoredCheckpointV4(options.Trust, options.ArtifactRoot, options.Checkpoint) + if err != nil { + return ContributionFilesResult{}, err + } + defer func() { _ = stored.reader.root.Close() }() + if stored.trusted.Definition.Schema != DefinitionSchemaV4 { + return ContributionFilesResult{}, errors.New("allocated contribution requires definition v4") + } + allocation, ok := stored.ancestry.allocations[options.AttemptID] + if !ok || allocation.Scope == nil { + return ContributionFilesResult{}, errors.New("candidate attempt is not allocated by the authenticated checkpoint ancestry") + } + var slot *DeliverySlotV2 + for index := range stored.ancestry.head.Deliveries { + candidate := &stored.ancestry.head.Deliveries[index] + if candidate.AttemptID == options.AttemptID { + slot = candidate + break + } + } + if slot == nil || slot.Kind != CheckpointSubmissionCandidate || slot.Status != DeliveryAllocated || slot.Scope != *allocation.Scope { + return ContributionFilesResult{}, errors.New("candidate allocation is no longer active at the authenticated checkpoint") + } + if err := stored.ancestry.head.Progress.currentTurn(*allocation.Scope); err != nil { + return ContributionFilesResult{}, fmt.Errorf("candidate allocation is not the current turn: %w", err) + } + state := stored.ancestry.head.Progress.Phase1 + if allocation.Scope.Phase == Phase2 { + if stored.ancestry.head.Progress.Phase2 == nil || stored.ancestry.head.Progress.Phase1Seal == nil { + return ContributionFilesResult{}, errors.New("phase2 allocation requires authenticated phase2 state and phase1 seal") + } + state = *stored.ancestry.head.Progress.Phase2 + } + root := stored.reader.path + contribution := ContributionFilesOptions{ + Trust: options.Trust, + Circuit: options.Circuit, + Phase: allocation.Scope.Phase, + Transcript: PhaseTranscriptPaths{RootDir: root, ChainPath: filepath.Join(root, state.Chain.Record.Name), ChainSignaturePath: filepath.Join(root, state.Chain.Signature.Name)}, + ParticipantID: allocation.Scope.ParticipantID, + ParticipantPrivateKeyPath: options.ParticipantPrivateKeyPath, + Environment: options.Environment, + ContributedAt: options.ContributedAt, + CandidateDir: options.CandidateDir, + ExpectedScope: allocation.Scope, + } + if allocation.Scope.Phase == Phase2 { + seal := stored.ancestry.head.Progress.Phase1Seal + contribution.Phase1SealPath = filepath.Join(root, seal.Record.Name) + contribution.Phase1SealSignaturePath = filepath.Join(root, seal.Signature.Name) + } + return CreateContributionCandidate(contribution) +} diff --git a/internal/mpcceremony/contribution_inventory_v4.go b/internal/mpcceremony/contribution_inventory_v4.go new file mode 100644 index 00000000..9ff4b711 --- /dev/null +++ b/internal/mpcceremony/contribution_inventory_v4.go @@ -0,0 +1,162 @@ +package mpcceremony + +import ( + "errors" + "io" + "os" + "time" +) + +// ContributionInventoryInspectionV4 describes authenticated local bytes, not +// valid contribution mathematics, actual erasure, acceptance or backend freshness. +// Uploaders must check these digests again: this inspection cannot freeze paths. +type ContributionInventoryInspectionV4 struct { + Scope ContributionScope `json:"scope"` + Predecessor SignedArtifactRefs `json:"predecessor"` + Computed CandidateInventory `json:"computed"` + ComputedCandidateID string `json:"computed_candidate_id"` + Complete *CandidateInventory `json:"complete,omitempty"` + CandidateResultID string `json:"candidate_result_id,omitempty"` +} + +// InspectContributionInventoryV4 reconstructs the fixed five-file candidate. +// expected must come from +// the caller's authenticated turn (or the exact retained operation on recovery). +// Extra local files are ignored, never added to either returned inventory. +func InspectContributionInventoryV4(trust TrustPaths, predecessor PhaseTranscriptPaths, expected ContributionScope, candidateDir string) (ContributionInventoryInspectionV4, error) { + trusted, err := LoadSignedDefinition(trust) + if err != nil { + return ContributionInventoryInspectionV4{}, err + } + d := trusted.Definition + if d.Schema != DefinitionSchemaV4 { + return ContributionInventoryInspectionV4{}, errors.New("contribution inventory inspection requires definition v4") + } + if err := expected.ValidateAssignment(d); err != nil { + return ContributionInventoryInspectionV4{}, err + } + chain, refs, err := LoadSignedChainExact(trusted, predecessor) + if err != nil { + return ContributionInventoryInspectionV4{}, err + } + head, err := chain.HeadRecordID() + if err != nil { + return ContributionInventoryInspectionV4{}, err + } + if chain.Phase != expected.Phase || len(chain.Records)+1 != int(expected.Index) || head != expected.ParentHeadID { + return ContributionInventoryInspectionV4{}, errors.New("signed predecessor differs from the exact expected turn") + } + reader, err := openCheckpointReaderV4(candidateDir) + if err != nil { + return ContributionInventoryInspectionV4{}, err + } + defer func() { _ = reader.root.Close() }() + result, err := inspectContributionInventoryV4(reader, d, chain, expected) + if err != nil { + return ContributionInventoryInspectionV4{}, err + } + result.Predecessor = refs + return result, nil +} + +func inspectContributionInventoryV4(r *checkpointReaderV4, d CeremonyDefinition, chain Chain, scope ContributionScope) (ContributionInventoryInspectionV4, error) { + var zero ContributionInventoryInspectionV4 + generated, attestation, err := inspectComputationOutputV4(r, d, chain, scope) + if err != nil { + return zero, err + } + names := []string{"erasure.json", "erasure.sig"} + data := map[string][]byte{} + for _, name := range names { + limit := int64(maxSignedRecordBytes) + if name == "attestation.sig" || name == "erasure.sig" { + limit = 4096 + } + b, err := readLocalInventoryRecordV4(r, name, limit) + if err != nil { + return zero, err + } + data[name] = b + } + participant, ok := d.ParticipantByID(scope.ParticipantID) + if !ok { + return zero, errors.New("candidate participant is not scheduled") + } + key, err := identityPublicKey(participant.Identity) + if err != nil { + return zero, err + } + var erasure ErasureAttestation + if err := VerifySignedRecord(data["erasure.json"], data["erasure.sig"], &erasure, participant.Identity.KeyID, key); err != nil { + return zero, candidateInvalid(err) + } + if err := ValidateErasureForContribution(attestation, erasure); err != nil { + return zero, candidateInvalid(err) + } + computed := CandidateInventory{Schema: CandidateInventorySchemaV1, Scope: scope, Files: append(append([]ArtifactRef{}, generated.Files...), + ArtifactRef{Name: "erasure.json", Digest: NewDigest(data["erasure.json"])}, + ArtifactRef{Name: "erasure.sig", Digest: NewDigest(data["erasure.sig"])})} + id, err := computed.ID() + if err != nil { + return zero, err + } + return ContributionInventoryInspectionV4{Scope: scope, Computed: computed, ComputedCandidateID: id, Complete: &computed, CandidateResultID: id}, nil +} + +// Only fixed basenames reach this helper. os.Root confines resolution, and +// descriptor identity/metadata checks reject substitution during the read. +func readLocalInventoryRecordV4(r *checkpointReaderV4, name string, limit int64) ([]byte, error) { + before, err := r.root.Lstat(name) + if err != nil { + return nil, err + } + if !before.Mode().IsRegular() || before.Size() <= 0 || before.Size() > limit { + return nil, errors.New("inventory record must be a bounded regular file") + } + f, err := r.root.Open(name) + if err != nil { + return nil, err + } + defer f.Close() + opened, err := f.Stat() + if err != nil { + return nil, err + } + if !os.SameFile(before, opened) || opened.Size() != before.Size() { + return nil, errors.New("inventory record changed while opening") + } + b, err := io.ReadAll(io.LimitReader(f, limit+1)) + if err != nil { + return nil, err + } + after, err := f.Stat() + if err != nil { + return nil, err + } + if int64(len(b)) != opened.Size() || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) { + return nil, errors.New("inventory record changed while reading") + } + return b, nil +} + +func validateAttestationSoftwareBinding(d CeremonyDefinition, a ContributionAttestation) error { + if !d.Software.AllowsToolBinary(a.ToolBinary) || d.Software.SourceCommit != a.SourceCommit || d.Software.GnarkVersion != a.GnarkVersion || d.Software.GnarkCryptoVersion != a.GnarkCryptoVersion || d.Software.DrandVersion != a.DrandVersion { + return errors.New("attestation software binding does not match definition") + } + return nil +} + +func validateContributionChronology(d CeremonyDefinition, chain Chain, a ContributionAttestation) error { + created, _ := time.Parse(time.RFC3339Nano, d.CreatedAt) + contributed, _ := time.Parse(time.RFC3339Nano, a.ContributedAt) + if !contributed.After(created) { + return errors.New("contributed_at must be strictly after the ceremony definition") + } + if len(chain.Records) > 0 { + previous, _ := time.Parse(time.RFC3339Nano, chain.Records[len(chain.Records)-1].AcceptedAt) + if !contributed.After(previous) { + return errors.New("contributed_at must be strictly after the previous acceptance") + } + } + return nil +} diff --git a/internal/mpcceremony/contribution_inventory_v4_test.go b/internal/mpcceremony/contribution_inventory_v4_test.go new file mode 100644 index 00000000..fd42108e --- /dev/null +++ b/internal/mpcceremony/contribution_inventory_v4_test.go @@ -0,0 +1,251 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +type inventoryFixtureV4 struct { + d CeremonyDefinition + trust TrustPaths + paths PhaseTranscriptPaths + scope ContributionScope + dir string + a ContributionAttestation +} + +func localInventoryFixtureV4(t *testing.T, phase Phase) inventoryFixtureV4 { + t.Helper() + d, _, db, ds := checkpointFixtureV4(t) + root, err := filepath.EvalSymlinks(t.TempDir()) + if err != nil { + t.Fatal(err) + } + putCheckpointTestFileV4(t, root, "ceremony.json", db) + putCheckpointTestFileV4(t, root, "ceremony.sig", ds) + key := adversarialPrivateKey(1) + putCheckpointTestFileV4(t, root, "coordinator.hex", []byte(hex.EncodeToString(key.Public().(ed25519.PublicKey)))) + genesis := d.Phase1Genesis + parent := "" + if phase == Phase2 { + genesis = ArtifactRef{Name: "phase2/genesis.bin", Digest: NewDigest([]byte("phase2 genesis"))} + parent = NewDigest([]byte("phase1 seal")).SHA256 + } + phaseID, err := ComputePhaseID(d.CeremonyID, phase, genesis, parent) + if err != nil { + t.Fatal(err) + } + chain, err := NewChain(d.CeremonyID, phase, phaseID, genesis) + if err != nil { + t.Fatal(err) + } + pair := putCheckpointTestPairV4(t, root, string(phase)+"/chain-0000", chain, d.Coordinator.KeyID, key) + head, err := chain.HeadRecordID() + if err != nil { + t.Fatal(err) + } + p := d.Roster[0].Identity + a := adversarialAttestation(t) + a.CeremonyID, a.Phase, a.PhaseID = d.CeremonyID, phase, phaseID + a.ParticipantID, a.ParticipantKeyID = p.ID, p.KeyID + a.PreviousPayload, a.PreviousAcceptanceID = genesis, head + // Deliberately not a Groth16 object: inspection verifies bytes/signatures, + // and must never claim that it performed mathematical acceptance. + payload := []byte("not a mathematical contribution") + a.OutputPayload = ArtifactRef{Name: fmt.Sprintf("%s/contributions/0001/contribution.bin", phase), Digest: NewDigest(payload)} + a.ToolBinary, a.SourceCommit = d.Software.ToolBinary, d.Software.SourceCommit + a.ContributedAt = "2026-07-23T12:01:00Z" + a, err = NewContributionAttestation(a) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + putCheckpointTestFileV4(t, dir, "contribution.bin", payload) + f := inventoryFixtureV4{d: d, trust: TrustPaths{DefinitionPath: filepath.Join(root, "ceremony.json"), DefinitionSignaturePath: filepath.Join(root, "ceremony.sig"), CoordinatorPublicKeyPath: filepath.Join(root, "coordinator.hex")}, paths: PhaseTranscriptPaths{RootDir: root, ChainPath: filepath.Join(root, pair.Record.Name), ChainSignaturePath: filepath.Join(root, pair.Signature.Name)}, scope: ContributionScope{CeremonyID: d.CeremonyID, Phase: phase, Index: 1, ParticipantID: p.ID, ParentHeadID: head}, dir: dir, a: a} + f.sign(t, a) + return f +} + +func (f inventoryFixtureV4) sign(t *testing.T, a ContributionAttestation) { + t.Helper() + a.AttestationID = "" + var err error + a, err = NewContributionAttestation(a) + if err != nil { + t.Fatal(err) + } + p := f.d.Roster[int(a.Index)-1].Identity + key := adversarialPrivateKey(0x10 + a.Index) + putCheckpointTestPairV4(t, f.dir, "attestation", a, p.KeyID, key) + e := adversarialErasure(t, a, "2026-07-23T12:02:00Z") + putCheckpointTestPairV4(t, f.dir, "erasure", e, p.KeyID, key) +} + +func (f inventoryFixtureV4) inspect() (ContributionInventoryInspectionV4, error) { + return InspectContributionInventoryV4(f.trust, f.paths, f.scope, f.dir) +} + +func TestContributionInventoryV4ReconstructsFixedFiveFiles(t *testing.T) { + for _, phase := range []Phase{Phase1, Phase2} { + t.Run(string(phase), func(t *testing.T) { + f := localInventoryFixtureV4(t, phase) + five, err := f.inspect() + if err != nil { + t.Fatal(err) + } + if len(five.Computed.Files) != 5 || five.ComputedCandidateID == "" || five.Complete == nil || five.CandidateResultID != five.ComputedCandidateID || five.Scope != f.scope { + t.Fatalf("bad computed result %+v", five) + } + putCheckpointTestFileV4(t, f.dir, "local-metadata.json", []byte("not uploaded")) + again, err := f.inspect() + if err != nil || again.CandidateResultID != five.CandidateResultID { + t.Fatal("extra file changed inventory", err) + } + }) + } +} + +func TestContributionInventoryV4RejectsPartialChangedAndUnboundWork(t *testing.T) { + for _, test := range []string{"scope", "phase", "participant", "software", "time", "payload", "symlink", "oversize"} { + t.Run(test, func(t *testing.T) { + f := localInventoryFixtureV4(t, Phase1) + _, err := f.inspect() + if err != nil { + t.Fatal(err) + } + switch test { + case "scope": + f.scope.ParentHeadID = NewDigest([]byte("other head")).SHA256 + case "phase": + f.scope.Phase = Phase2 + case "participant": + f.scope.ParticipantID = f.d.Roster[1].Identity.ID + case "software": + a := f.a + a.SourceCommit = strings.Repeat("aa", 20) + f.sign(t, a) + case "time": + a := f.a + a.ContributedAt = f.d.CreatedAt + f.sign(t, a) + case "payload": + putCheckpointTestFileV4(t, f.dir, "contribution.bin", []byte("changed")) + case "symlink": + if err := os.Rename(filepath.Join(f.dir, "attestation.json"), filepath.Join(f.dir, "other.json")); err != nil { + t.Fatal(err) + } + if err := os.Symlink("other.json", filepath.Join(f.dir, "attestation.json")); err != nil { + t.Fatal(err) + } + case "oversize": + file, err := os.OpenFile(filepath.Join(f.dir, "attestation.sig"), os.O_WRONLY, 0600) + if err != nil { + t.Fatal(err) + } + err = file.Truncate(4097) + file.Close() + if err != nil { + t.Fatal(err) + } + } + got, err := f.inspect() + if err == nil || got.ComputedCandidateID != "" || got.CandidateResultID != "" { + t.Fatalf("accepted %s or leaked partial success: %+v %v", test, got, err) + } + }) + } +} + +func TestContributionInventoryV4ClassifiesOnlySemanticCandidateFailures(t *testing.T) { + t.Run("signed candidate semantics", func(t *testing.T) { + f := localInventoryFixtureV4(t, Phase1) + a := f.a + a.SourceCommit = strings.Repeat("aa", 20) + f.sign(t, a) + if _, err := f.inspect(); err == nil || !IsCandidateInvalid(err) { + t.Fatalf("candidate semantic failure classification = %v, want candidate invalid", err) + } + }) + + t.Run("stable payload digest mismatch", func(t *testing.T) { + f := localInventoryFixtureV4(t, Phase1) + path := filepath.Join(f.dir, "contribution.bin") + payload, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + payload[len(payload)-1] ^= 1 + if err := os.WriteFile(path, payload, 0600); err != nil { + t.Fatal(err) + } + if _, err := f.inspect(); err == nil || !IsCandidateInvalid(err) { + t.Fatalf("stable payload mismatch classification = %v, want candidate invalid", err) + } + }) + + t.Run("candidate file missing", func(t *testing.T) { + f := localInventoryFixtureV4(t, Phase1) + if err := os.Remove(filepath.Join(f.dir, "attestation.json")); err != nil { + t.Fatal(err) + } + if _, err := f.inspect(); err == nil || IsCandidateInvalid(err) { + t.Fatalf("operational failure classification = %v, must not be candidate invalid", err) + } + }) +} + +func TestContributionInventoryV4LaterTurnAndPredecessorTime(t *testing.T) { + for _, phase := range []Phase{Phase1, Phase2} { + t.Run(string(phase), func(t *testing.T) { + f := localInventoryFixtureV4(t, phase) + b, err := os.ReadFile(f.paths.ChainPath) + if err != nil { + t.Fatal(err) + } + var chain Chain + if err := UnmarshalCanonical(b, &chain); err != nil { + t.Fatal(err) + } + record := adversarialChainRecord(t, f.d, chain.PhaseID, 1, f.d.Roster[0].Identity.ID, chain.Genesis, f.scope.ParentHeadID, "previous-output") + record.Phase = phase + record.AcceptedAt = "2026-07-23T12:00:30Z" + record, err = NewChainRecord(record) + if err != nil { + t.Fatal(err) + } + if err := chain.Append(record); err != nil { + t.Fatal(err) + } + pair := putCheckpointTestPairV4(t, f.paths.RootDir, string(phase)+"/chain-0001", chain, f.d.Coordinator.KeyID, adversarialPrivateKey(1)) + f.paths.ChainPath = filepath.Join(f.paths.RootDir, pair.Record.Name) + f.paths.ChainSignaturePath = filepath.Join(f.paths.RootDir, pair.Signature.Name) + f.scope.Index = 2 + f.scope.ParticipantID = f.d.Roster[1].Identity.ID + f.scope.ParentHeadID = record.RecordID + f.a.Index = 2 + f.a.ParticipantID = f.scope.ParticipantID + f.a.ParticipantKeyID = f.d.Roster[1].Identity.KeyID + f.a.PreviousAcceptanceID = record.RecordID + f.a.PreviousPayload = record.OutputPayload + f.a.OutputPayload.Name = fmt.Sprintf("%s/contributions/0002/contribution.bin", phase) + f.sign(t, f.a) + i, err := f.inspect() + if err != nil { + t.Fatal(err) + } + if i.CandidateResultID == "" { + t.Fatal("complete candidate result ID missing") + } + f.a.ContributedAt = record.AcceptedAt + f.sign(t, f.a) + if _, err := f.inspect(); err == nil || !strings.Contains(err.Error(), "strictly after the previous acceptance") { + t.Fatal(err) + } + }) + } +} diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index 235c9f03..aab636d6 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -856,10 +856,13 @@ func VerifyProductionDecisionEvidence( } func validateProductionDecisionBinding(definition CeremonyDefinition, decision ProductionDecision) error { + if definition.Schema == DefinitionSchemaV4 { + return errors.New("definition v4 requires the versioned trusted-coordinator decision verification path") + } if decision.CeremonyID != definition.CeremonyID { return errors.New("production decision ceremony_id does not match the signed definition") } - if definition.Schema == DefinitionSchema { + if definition.Schema == DefinitionSchemaV3 { if decision.Schema != ProductionDecisionSchema || decision.AssurancePolicy == nil || *decision.AssurancePolicy != *definition.AssurancePolicy { return errors.New("production decision assurance_policy does not exactly match signed definition") } @@ -902,7 +905,10 @@ func validateProductionDecisionBinding(definition CeremonyDefinition, decision P } func expectedFinalTranscriptSchema(definition CeremonyDefinition) string { - if definition.Schema == DefinitionSchema { + if definition.Schema == DefinitionSchemaV4 { + return FinalTranscriptSchemaV3 + } + if definition.Schema == DefinitionSchemaV3 { return FinalTranscriptSchema } return FinalTranscriptSchemaV1 diff --git a/internal/mpcceremony/decision_v3.go b/internal/mpcceremony/decision_v3.go new file mode 100644 index 00000000..7c49c250 --- /dev/null +++ b/internal/mpcceremony/decision_v3.go @@ -0,0 +1,357 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path" + "slices" + "strings" +) + +const ( + ProductionDecisionSchemaV3 = "proof-tool-mpc-production-decision-v3" + ProductionDecisionDraftSchemaV3 = "proof-tool-mpc-production-decision-draft-v3" + GateSourceRelease ProductionGate = "source-release" + DecisionSourceReportV3 = "decision/evidence/source-release.json" +) + +// FinalReleaseEvidenceV4 binds a complete verified package through the exact +// coordinator checkpoint. It is not an operator-selected list of package files. +type FinalReleaseEvidenceV4 struct { + ReleaseID string `json:"release_id"` + CeremonyID string `json:"ceremony_id"` + FinalReleaseCheckpoint SignedArtifactRefs `json:"final_release_checkpoint"` + CandidateID string `json:"candidate_id"` +} + +func NewFinalReleaseEvidenceV4(ceremonyID string, checkpoint SignedArtifactRefs, candidateID string) (FinalReleaseEvidenceV4, error) { + r := FinalReleaseEvidenceV4{CeremonyID: ceremonyID, FinalReleaseCheckpoint: checkpoint, CandidateID: candidateID} + var err error + r.ReleaseID, err = computeFinalReleaseIDV4(r) + if err != nil { + return FinalReleaseEvidenceV4{}, err + } + return r, r.Validate() +} + +func computeFinalReleaseIDV4(r FinalReleaseEvidenceV4) (string, error) { + r.ReleaseID = "" + return canonicalHash("proof-tool/mpc-ceremony/signed-release/v2", r) +} + +func (r FinalReleaseEvidenceV4) Validate() error { + for _, id := range []string{r.ReleaseID, r.CeremonyID, r.CandidateID} { + if err := validateHashID("release binding", id); err != nil { + return err + } + } + if err := r.FinalReleaseCheckpoint.Validate(); err != nil { + return err + } + for _, ref := range signedArtifacts(&r.FinalReleaseCheckpoint) { + if err := validatePortableStorageName(ref.Name); err != nil { + return err + } + } + id, err := computeFinalReleaseIDV4(r) + if err != nil { + return err + } + if id != r.ReleaseID { + return errors.New("release ID does not match exact ceremony, checkpoint and candidate") + } + return nil +} + +type SourceReleaseEvidenceV4 struct { + SourceCommit string `json:"source_commit"` + VerificationReport ArtifactRef `json:"verification_report"` +} + +type DecisionAuditorV3 struct { + AuditorID string `json:"auditor_id"` + AuditorKeyID string `json:"auditor_key_id"` +} + +type ExternalAuditEvidenceV3 struct { + Auditor Identity `json:"auditor"` + Report ArtifactRef `json:"report"` + Signoff ArtifactRef `json:"signoff"` +} + +type K21RehearsalEvidenceV3 struct { + Circuit CircuitBinding `json:"circuit"` + Evidence ArtifactRef `json:"evidence"` +} + +type ProductionGateResultV3 struct { + Gate ProductionGate `json:"gate"` + Status ProductionGateStatus `json:"status"` + Evidence []ArtifactRef `json:"evidence"` + Rationale string `json:"rationale"` +} + +type ProductionDecisionV3 struct { + Schema string `json:"schema"` + DecisionID string `json:"decision_id"` + CeremonyID string `json:"ceremony_id"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy"` + Release FinalReleaseEvidenceV4 `json:"release"` + SourceRelease SourceReleaseEvidenceV4 `json:"source_release"` + Auditors []DecisionAuditorV3 `json:"auditors"` + ExternalAudits []ExternalAuditEvidenceV3 `json:"external_audits"` + K21Rehearsal K21RehearsalEvidenceV3 `json:"k21_rehearsal"` + MainnetDeploymentPlan ArtifactRef `json:"mainnet_deployment_plan"` + FormalChecklist ArtifactRef `json:"formal_checklist"` + Gates []ProductionGateResultV3 `json:"gates"` + Decision ProductionDecisionOutcome `json:"decision"` + DecidedAt string `json:"decided_at"` +} + +func decisionGatesV3() []ProductionGate { + return append([]ProductionGate{GateSourceRelease}, requiredProductionGates[:]...) +} + +func packageDerivedGateV3(g ProductionGate) bool { + switch g { + case GateSignedRelease, GateOperationalEvidence, GateIndependentAudits, GatePublicWitnessing, GateImmutableMirrors: + return true + } + return false +} + +func optionalDecisionGateV3(g ProductionGate, p AssurancePolicy) (bool, bool) { + switch g { + case GateIndependentAudits: + return true, p.PassingCeremonyAudits > 0 + case GateExternalAudit: + return true, p.ExternalSecurityAuditSignoffs > 0 + case GatePublicWitnessing: + return true, p.PublicWitnessesPerPhase > 0 + case GateImmutableMirrors: + return true, p.MirrorsPerAcceptedHead > 0 + } + return false, true +} + +func validateDecisionEvidenceRefV3(ref ArtifactRef) error { + if err := ref.Validate(); err != nil { + return err + } + if err := validatePortableStorageName(ref.Name); err != nil { + return err + } + if !strings.HasPrefix(ref.Name, "decision/evidence/") { + return errors.New("decision evidence must be under decision/evidence/") + } + if ref.Digest.Size > maxSignedRecordBytes { + return errors.New("decision evidence exceeds bounded report size") + } + return nil +} + +func (g ProductionGateResultV3) validate(p AssurancePolicy) error { + if g.Evidence == nil || len(g.Evidence) > 32 { + return errors.New("gate evidence must be explicit and bounded") + } + if err := validateV4ArtifactSet(g.Evidence, 32); err != nil { + return err + } + for _, ref := range g.Evidence { + if err := validateDecisionEvidenceRefV3(ref); err != nil { + return err + } + } + if g.Rationale != strings.TrimSpace(g.Rationale) || len(g.Rationale) > 2048 { + return errors.New("gate rationale must be trimmed and bounded") + } + optional, enabled := optionalDecisionGateV3(g.Gate, p) + if optional && !enabled { + if g.Status != GateNotRequired || len(g.Evidence) != 0 || g.Rationale == "" { + return errors.New("disabled assurance gate must be NOT_REQUIRED with an explanation and no evidence") + } + return nil + } + if packageDerivedGateV3(g.Gate) && (g.Status != GatePASS || len(g.Evidence) != 0) { + return errors.New("verified package gate must be PASS with no duplicated evidence") + } + switch g.Status { + case GatePASS: + if packageDerivedGateV3(g.Gate) { + if len(g.Evidence) != 0 { + return errors.New("package-derived gate must not duplicate evidence") + } + } else if len(g.Evidence) == 0 { + return errors.New("external PASS gate needs exact evidence") + } + case GateFAIL, GatePENDING: + if g.Rationale == "" { + return errors.New("failed/pending gate needs an explanation") + } + case GateNotRequired: + return errors.New("enabled or mandatory gate cannot be NOT_REQUIRED") + default: + return errors.New("unsupported gate status") + } + return nil +} + +func NewProductionDecisionV3(value ProductionDecisionV3) (ProductionDecisionV3, error) { + // No implicit schema or missing-array upgrade: callers select the new format. + value.DecisionID = "" + id, err := computeProductionDecisionIDV3(value) + if err != nil { + return ProductionDecisionV3{}, err + } + value.DecisionID = id + return value, value.Validate() +} + +func computeProductionDecisionIDV3(value ProductionDecisionV3) (string, error) { + value.DecisionID = "" + return canonicalHash("proof-tool/mpc-ceremony/production-decision/v3", value) +} + +func (d ProductionDecisionV3) Validate() error { + if d.Schema != ProductionDecisionSchemaV3 || d.AssurancePolicy == nil || d.Auditors == nil || d.ExternalAudits == nil || d.Gates == nil { + return errors.New("decision v3 requires its explicit schema, policy and arrays") + } + if err := validateHashID("decision_id", d.DecisionID); err != nil { + return err + } + id, err := computeProductionDecisionIDV3(d) + if err != nil { + return err + } + if id != d.DecisionID { + return errors.New("decision ID differs from exact contents") + } + if err := d.Release.Validate(); err != nil { + return err + } + if d.CeremonyID != d.Release.CeremonyID { + return errors.New("decision and release identify different ceremonies") + } + if err := validateHex(d.SourceRelease.SourceCommit, 20); err != nil { + return err + } + if d.SourceRelease.VerificationReport.Name != DecisionSourceReportV3 { + return errors.New("source report must use its canonical evidence name") + } + if len(d.Auditors) > MaxAuditors || len(d.ExternalAudits) > MaxAuditors { + return errors.New("decision auditor count exceeds maximum") + } + if err := d.AssurancePolicy.Validate(ModeProduction, len(d.Auditors)); err != nil { + return err + } + if len(d.Auditors) < int(d.AssurancePolicy.PassingCeremonyAudits) || d.AssurancePolicy.PassingCeremonyAudits == 0 && len(d.Auditors) != 0 || len(d.ExternalAudits) < int(d.AssurancePolicy.ExternalSecurityAuditSignoffs) || d.AssurancePolicy.ExternalSecurityAuditSignoffs == 0 && len(d.ExternalAudits) != 0 { + return errors.New("decision audit counts differ from signed assurance policy") + } + keys := map[string]bool{} + for i, a := range d.Auditors { + if err := validateID("auditor_id", a.AuditorID); err != nil { + return err + } + if err := validateID("auditor_key_id", a.AuditorKeyID); err != nil { + return err + } + if i > 0 && d.Auditors[i-1].AuditorID >= a.AuditorID || keys[a.AuditorKeyID] { + return errors.New("auditors must be sorted and distinct") + } + keys[a.AuditorKeyID] = true + } + externalKeys := map[string]bool{} + for i, a := range d.ExternalAudits { + if err := a.Auditor.Validate(); err != nil { + return err + } + if i > 0 && d.ExternalAudits[i-1].Auditor.ID >= a.Auditor.ID || externalKeys[a.Auditor.PublicKeyFingerprint] || a.Report.Name == a.Signoff.Name { + return errors.New("external auditors and report/signoff paths must be distinct") + } + externalKeys[a.Auditor.PublicKeyFingerprint] = true + if a.Signoff.Digest.Size > 4096 { + return errors.New("external signoff exceeds signature size limit") + } + } + b := d.K21Rehearsal.Circuit + if err := b.Validate(); err != nil { + return err + } + if b.KeyVersion != KeyVersionDestinationV2 || b.DomainSize != 1<<21 { + return errors.New("production decision requires the exact K21 destination-v2 rehearsal") + } + if path.Ext(d.FormalChecklist.Name) != ".md" { + return errors.New("formal checklist must be Markdown") + } + expected := decisionGatesV3() + if len(d.Gates) != len(expected) { + return errors.New("decision requires every V3 production gate exactly once") + } + all := true + for i, g := range d.Gates { + if g.Gate != expected[i] { + return fmt.Errorf("wrong production gate at index %d", i) + } + if err := g.validate(*d.AssurancePolicy); err != nil { + return fmt.Errorf("gate %s: %w", g.Gate, err) + } + var bound []ArtifactRef + switch g.Gate { + case GateSourceRelease: + bound = []ArtifactRef{d.SourceRelease.VerificationReport} + case GateK21Rehearsal: + bound = []ArtifactRef{d.K21Rehearsal.Evidence} + case GateMainnetDeploymentPlan: + bound = []ArtifactRef{d.MainnetDeploymentPlan} + case GateFormalChecklist: + bound = []ArtifactRef{d.FormalChecklist} + case GateExternalAudit: + bound = []ArtifactRef{} + for _, audit := range d.ExternalAudits { + bound = append(bound, audit.Report, audit.Signoff) + } + slices.SortFunc(bound, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + } + if bound != nil && !slices.Equal(g.Evidence, bound) { + return fmt.Errorf("gate %s must bind its exact structured evidence", g.Gate) + } + all = all && (g.Status == GatePASS || g.Status == GateNotRequired) + } + if d.Decision != DecisionGO && d.Decision != DecisionNOGO || d.Decision == DecisionGO && !all || d.Decision == DecisionNOGO && all { + return errors.New("decision outcome disagrees with gate results") + } + if err := validateTimestamp("decided_at", d.DecidedAt); err != nil { + return err + } + _, err = decisionExternalArtifactsV3(d) + return err +} + +func decisionExternalArtifactsV3(d ProductionDecisionV3) ([]ArtifactRef, error) { + refs := []ArtifactRef{d.SourceRelease.VerificationReport, d.K21Rehearsal.Evidence, d.MainnetDeploymentPlan, d.FormalChecklist} + for _, a := range d.ExternalAudits { + refs = append(refs, a.Report, a.Signoff) + } + for _, g := range d.Gates { + refs = append(refs, g.Evidence...) + } + byName := map[string]ArtifactRef{} + for _, r := range refs { + if err := validateDecisionEvidenceRefV3(r); err != nil { + return nil, err + } + if old, ok := byName[r.Name]; ok && old != r { + return nil, errors.New("decision evidence name has conflicting digests") + } + byName[r.Name] = r + } + result := make([]ArtifactRef, 0, len(byName)) + for _, r := range byName { + result = append(result, r) + } + slices.SortFunc(result, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + if err := validateV4ArtifactSet(result, 512+2*MaxAuditors); err != nil { + return nil, err + } + return result, nil +} diff --git a/internal/mpcceremony/decision_v3_draft.go b/internal/mpcceremony/decision_v3_draft.go new file mode 100644 index 00000000..f80e5807 --- /dev/null +++ b/internal/mpcceremony/decision_v3_draft.go @@ -0,0 +1,69 @@ +package mpcceremony + +import "errors" + +var ( + errDecisionDraftSchemaV3 = errors.New("explicit production decision draft v3 is required") + errDecisionDraftSizeV3 = errors.New("decision draft exceeds record size limit") +) + +type FinalReleaseEvidenceDraftV4 struct { + FinalReleaseCheckpoint SignedArtifactRefs `json:"final_release_checkpoint"` + CandidateID string `json:"candidate_id"` +} + +// Drafts omit content-derived IDs. They still name exact reviewed evidence; +// preparation never infers PASS from a file existing. +type ProductionDecisionDraftV3 struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy"` + Release FinalReleaseEvidenceDraftV4 `json:"release"` + SourceRelease SourceReleaseEvidenceV4 `json:"source_release"` + Auditors []DecisionAuditorV3 `json:"auditors"` + ExternalAudits []ExternalAuditEvidenceV3 `json:"external_audits"` + K21Rehearsal K21RehearsalEvidenceV3 `json:"k21_rehearsal"` + MainnetDeploymentPlan ArtifactRef `json:"mainnet_deployment_plan"` + FormalChecklist ArtifactRef `json:"formal_checklist"` + Gates []ProductionGateResultV3 `json:"gates"` + Decision ProductionDecisionOutcome `json:"decision"` + DecidedAt string `json:"decided_at"` +} + +func (d ProductionDecisionDraftV3) Validate() error { + _, err := d.decision() + return err +} + +func (d ProductionDecisionDraftV3) decision() (ProductionDecisionV3, error) { + if d.Schema != ProductionDecisionDraftSchemaV3 { + return ProductionDecisionV3{}, errDecisionDraftSchemaV3 + } + release, err := NewFinalReleaseEvidenceV4(d.CeremonyID, d.Release.FinalReleaseCheckpoint, d.Release.CandidateID) + if err != nil { + return ProductionDecisionV3{}, err + } + return NewProductionDecisionV3(ProductionDecisionV3{Schema: ProductionDecisionSchemaV3, CeremonyID: d.CeremonyID, AssurancePolicy: cloneAssurancePolicy(d.AssurancePolicy), Release: release, SourceRelease: d.SourceRelease, Auditors: d.Auditors, ExternalAudits: d.ExternalAudits, K21Rehearsal: d.K21Rehearsal, MainnetDeploymentPlan: d.MainnetDeploymentPlan, FormalChecklist: d.FormalChecklist, Gates: d.Gates, Decision: d.Decision, DecidedAt: d.DecidedAt}) +} + +func PrepareProductionDecisionV4(trust TrustPaths, root string, draftBytes []byte) (ProductionDecisionV3, []byte, error) { + if len(draftBytes) > maxSignedRecordBytes { + return ProductionDecisionV3{}, nil, errDecisionDraftSizeV3 + } + var draft ProductionDecisionDraftV3 + if err := UnmarshalCanonical(draftBytes, &draft); err != nil { + return ProductionDecisionV3{}, nil, err + } + decision, err := draft.decision() + if err != nil { + return ProductionDecisionV3{}, nil, err + } + raw, err := MarshalCanonical(decision) + if err != nil { + return ProductionDecisionV3{}, nil, err + } + if _, err := VerifyProductionDecisionEvidenceV4(VerifyProductionDecisionEvidenceV4Options{Trust: trust, ArtifactRoot: root, DecisionBytes: raw}); err != nil { + return ProductionDecisionV3{}, nil, err + } + return decision, raw, nil +} diff --git a/internal/mpcceremony/decision_v3_test.go b/internal/mpcceremony/decision_v3_test.go new file mode 100644 index 00000000..5bbac76a --- /dev/null +++ b/internal/mpcceremony/decision_v3_test.go @@ -0,0 +1,242 @@ +package mpcceremony + +import ( + "bytes" + "encoding/json" + "slices" + "strings" + "testing" +) + +// Structural fixture only: no production package, independence or GO outcome +// is established by these known-key records and placeholder evidence hashes. +func decisionFixtureV3(t *testing.T) (CeremonyDefinition, ProductionDecisionV3) { + t.Helper() + d := trustedCoordinatorDefinition(t) + d.AssurancePolicy = &AssurancePolicy{} + d.Auditors = []Identity{} + var err error + d, err = FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + ref := func(name string) ArtifactRef { return checkpointArtifact("decision/evidence/"+name, "public fixture") } + release, err := NewFinalReleaseEvidenceV4(d.CeremonyID, checkpointSigned("checkpoints/final-release"), NewDigest([]byte("candidate")).SHA256) + if err != nil { + t.Fatal(err) + } + x := ProductionDecisionV3{Schema: ProductionDecisionSchemaV3, CeremonyID: d.CeremonyID, AssurancePolicy: cloneAssurancePolicy(d.AssurancePolicy), Release: release, SourceRelease: SourceReleaseEvidenceV4{SourceCommit: d.Software.SourceCommit, VerificationReport: ref("source-release.json")}, Auditors: []DecisionAuditorV3{}, ExternalAudits: []ExternalAuditEvidenceV3{}, K21Rehearsal: K21RehearsalEvidenceV3{Circuit: d.Circuit, Evidence: ref("k21.json")}, MainnetDeploymentPlan: ref("deployment.md"), FormalChecklist: ref("checklist.md"), Decision: DecisionGO, DecidedAt: "2026-07-24T12:00:00Z"} + for _, gate := range decisionGatesV3() { + g := ProductionGateResultV3{Gate: gate, Status: GatePASS, Evidence: []ArtifactRef{}} + if optional, enabled := optionalDecisionGateV3(gate, *d.AssurancePolicy); optional && !enabled { + g.Status = GateNotRequired + g.Rationale = "Disabled in signed policy" + } else if !packageDerivedGateV3(gate) { + g.Evidence = []ArtifactRef{ref(string(gate) + ".txt")} + } + if gate == GateSourceRelease { + g.Evidence = []ArtifactRef{x.SourceRelease.VerificationReport} + } + switch gate { + case GateK21Rehearsal: + g.Evidence = []ArtifactRef{x.K21Rehearsal.Evidence} + case GateMainnetDeploymentPlan: + g.Evidence = []ArtifactRef{x.MainnetDeploymentPlan} + case GateFormalChecklist: + g.Evidence = []ArtifactRef{x.FormalChecklist} + } + x.Gates = append(x.Gates, g) + } + x, err = NewProductionDecisionV3(x) + if err != nil { + t.Fatal(err) + } + return d, x +} + +func TestDecisionV3CanonicalAndLegacySeparation(t *testing.T) { + d, x := decisionFixtureV3(t) + if err := validateProductionDecisionBindingV4(d, x); err != nil { + t.Fatal(err) + } + raw, err := MarshalCanonical(x) + if err != nil { + t.Fatal(err) + } + var copy ProductionDecisionV3 + if err := UnmarshalCanonical(raw, ©); err != nil { + t.Fatal(err) + } + var old ProductionDecision + if err := UnmarshalCanonical(raw, &old); err == nil { + t.Fatal("legacy decision accepted v3") + } + legacy := adversarialDefinition(t) + if err := validateProductionDecisionBindingV4(legacy, x); err == nil { + t.Fatal("old definition accepted v3") + } + if err := validateProductionDecisionBinding(d, ProductionDecision{}); err == nil { + t.Fatal("legacy decision binding accepted definition v4") + } + for _, schema := range []string{"", ProductionDecisionSchema, ProductionDecisionSchemaV1} { + bad := x + bad.Schema = schema + if _, err := NewProductionDecisionV3(bad); err == nil { + t.Fatal("implicit/cross-version schema accepted") + } + } + for _, extra := range []string{`,"uri":"https://example.invalid/?token=secret"`, `,"signed_tag":"old"`, `,"signature_format":"openpgp-primary-key-v4"`} { + // Unknown fields remain rejected at the top-level too. + bad := append(bytes.Clone(raw[:len(raw)-1]), []byte(extra+"}")...) + if err := UnmarshalCanonical(bad, ©); err == nil { + t.Fatal("unknown legacy/credential field accepted") + } + } +} + +func TestDecisionV3ReleaseIDBindsAllInputs(t *testing.T) { + _, d := decisionFixtureV3(t) + for _, change := range []func(*FinalReleaseEvidenceV4){ + func(r *FinalReleaseEvidenceV4) { r.CeremonyID = NewDigest([]byte("other")).SHA256 }, + func(r *FinalReleaseEvidenceV4) { r.CandidateID = NewDigest([]byte("other")).SHA256 }, + func(r *FinalReleaseEvidenceV4) { r.FinalReleaseCheckpoint.Record.Digest = NewDigest([]byte("other")) }, + func(r *FinalReleaseEvidenceV4) { + r.FinalReleaseCheckpoint.Signature.Digest = NewDigest([]byte("other")) + }, + } { + r := d.Release + change(&r) + if err := r.Validate(); err == nil { + t.Fatal("changed release retained old ID") + } + } +} + +func TestDecisionV3RejectsWrongPolicyEvidenceAndGates(t *testing.T) { + d, x := decisionFixtureV3(t) + for name, change := range map[string]func(*ProductionDecisionV3){ + "missing-policy": func(x *ProductionDecisionV3) { x.AssurancePolicy = nil }, + "implicit-auditors": func(x *ProductionDecisionV3) { x.Auditors = nil }, + "implicit-external": func(x *ProductionDecisionV3) { x.ExternalAudits = nil }, + "missing-gate": func(x *ProductionDecisionV3) { x.Gates = x.Gates[1:] }, + "source-other-file": func(x *ProductionDecisionV3) { + x.Gates[0].Evidence = []ArtifactRef{checkpointArtifact("decision/evidence/other.json", "x")} + }, + "source-unscoped": func(x *ProductionDecisionV3) { x.SourceRelease.VerificationReport.Name = "source-release.json" }, + "source-too-large": func(x *ProductionDecisionV3) { + x.SourceRelease.VerificationReport.Digest.Size = maxSignedRecordBytes + 1 + x.Gates[0].Evidence = []ArtifactRef{x.SourceRelease.VerificationReport} + }, + "tiny-rehearsal": func(x *ProductionDecisionV3) { x.K21Rehearsal.Circuit.DomainSize = 8 }, + "conflicting-report": func(x *ProductionDecisionV3) { + x.FormalChecklist = checkpointArtifact(x.MainnetDeploymentPlan.Name, "other") + }, + "private-path": func(x *ProductionDecisionV3) { x.MainnetDeploymentPlan.Name = "keys/signing.hex" }, + "disabled-audit": func(x *ProductionDecisionV3) { + x.Auditors = []DecisionAuditorV3{{AuditorID: "injected", AuditorKeyID: "key"}} + }, + "disabled-gate-pass": func(x *ProductionDecisionV3) { + for i := range x.Gates { + if x.Gates[i].Gate == GatePublicWitnessing { + x.Gates[i].Status = GatePASS + } + } + }, + "package-external-list": func(x *ProductionDecisionV3) { x.Gates[1].Evidence = []ArtifactRef{x.SourceRelease.VerificationReport} }, + } { + t.Run(name, func(t *testing.T) { + raw, _ := json.Marshal(x) + var bad ProductionDecisionV3 + _ = json.Unmarshal(raw, &bad) + change(&bad) + if _, err := NewProductionDecisionV3(bad); err == nil { + t.Fatal("invalid decision accepted") + } + }) + } + bad := x + bad.SourceRelease.SourceCommit = strings.Repeat("ab", 20) + bad, _ = NewProductionDecisionV3(bad) + if err := validateProductionDecisionBindingV4(d, bad); err == nil { + t.Fatal("wrong source commit accepted") + } + bad = x + policy := *x.AssurancePolicy + policy.PublicWitnessesPerPhase = 1 + bad.AssurancePolicy = &policy + for i := range bad.Gates { + if bad.Gates[i].Gate == GatePublicWitnessing { + bad.Gates[i].Status = GatePASS + bad.Gates[i].Rationale = "" + } + } + bad, err := NewProductionDecisionV3(bad) + if err != nil { + t.Fatal(err) + } + if err := validateProductionDecisionBindingV4(d, bad); err == nil { + t.Fatal("changed signed policy accepted") + } +} + +func TestDecisionV3RequiredSigners(t *testing.T) { + d, x := decisionFixtureV3(t) + want := []string{string(DecisionSignerCoordinator) + "\x00" + d.Coordinator.ID, string(DecisionSignerRelease) + "\x00" + d.ReleaseSigner.ID} + slices.Sort(want) + if !slices.Equal(requiredDecisionSignersV4(d, x), want) { + t.Fatal("zero-audit threshold changed") + } + if _, err := decisionSignerIdentityV4(d, x, DecisionSignerAuditor, "injected"); err == nil { + t.Fatal("disabled auditor authorized") + } + if _, err := decisionSignerIdentityV4(d, x, DecisionSignerCoordinator, d.ReleaseSigner.ID); err == nil { + t.Fatal("wrong role authorized") + } + a := adversarialIdentity(t, "auditor", 3) + d.Auditors = []Identity{a} + x.Auditors = []DecisionAuditorV3{{AuditorID: a.ID, AuditorKeyID: a.KeyID}} + if len(requiredDecisionSignersV4(d, x)) != 3 { + t.Fatal("auditor consent omitted") + } + if got, err := decisionSignerIdentityV4(d, x, DecisionSignerAuditor, a.ID); err != nil || got != a { + t.Fatalf("auditor: %v", err) + } +} + +func decisionDraftFixtureV3(x ProductionDecisionV3) ProductionDecisionDraftV3 { + return ProductionDecisionDraftV3{Schema: ProductionDecisionDraftSchemaV3, CeremonyID: x.CeremonyID, AssurancePolicy: cloneAssurancePolicy(x.AssurancePolicy), Release: FinalReleaseEvidenceDraftV4{FinalReleaseCheckpoint: x.Release.FinalReleaseCheckpoint, CandidateID: x.Release.CandidateID}, SourceRelease: x.SourceRelease, Auditors: x.Auditors, ExternalAudits: x.ExternalAudits, K21Rehearsal: x.K21Rehearsal, MainnetDeploymentPlan: x.MainnetDeploymentPlan, FormalChecklist: x.FormalChecklist, Gates: x.Gates, Decision: x.Decision, DecidedAt: x.DecidedAt} +} + +func TestDecisionV3DraftDerivesIDsAndRejectsImplicitUpgrade(t *testing.T) { + _, x := decisionFixtureV3(t) + draft := decisionDraftFixtureV3(x) + raw, err := MarshalCanonical(draft) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(raw, []byte(`"decision_id"`)) || bytes.Contains(raw, []byte(`"release_id"`)) { + t.Fatal("draft included derived IDs") + } + var decoded ProductionDecisionDraftV3 + if err := UnmarshalCanonical(raw, &decoded); err != nil { + t.Fatal(err) + } + decision, err := decoded.decision() + if err != nil { + t.Fatal(err) + } + if decision.DecisionID != x.DecisionID || decision.Release.ReleaseID != x.Release.ReleaseID { + t.Fatal("draft changed exact IDs") + } + for _, schema := range []string{"", ProductionDecisionDraftSchema, ProductionDecisionDraftSchemaV1} { + bad := draft + bad.Schema = schema + if err := bad.Validate(); err == nil { + t.Fatal("implicit/cross-version draft accepted") + } + } + bad := append(bytes.Clone(raw[:len(raw)-1]), []byte(`,"decision_id":"stale"}`)...) + if err := UnmarshalCanonical(bad, &decoded); err == nil { + t.Fatal("operator-chosen decision ID accepted") + } +} diff --git a/internal/mpcceremony/decision_v3_verify.go b/internal/mpcceremony/decision_v3_verify.go new file mode 100644 index 00000000..c6ca0373 --- /dev/null +++ b/internal/mpcceremony/decision_v3_verify.go @@ -0,0 +1,313 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "errors" + "fmt" + "path/filepath" + "slices" + "strings" + "time" +) + +type VerifyProductionDecisionEvidenceV4Options struct { + Trust TrustPaths + ArtifactRoot string + DecisionBytes []byte +} + +type VerifyProductionDecisionV4Options struct { + VerifyProductionDecisionEvidenceV4Options + SignatureBytes [][]byte +} + +type VerifiedProductionDecisionV4 struct { + Decision ProductionDecisionV3 + DecisionDigest Digest + VerifiedSigners []string + VerifiedExternalArtifacts []ArtifactRef + ReleaseInventory FinalReleaseInventoryV4 +} + +func validateProductionDecisionBindingV4(d CeremonyDefinition, decision ProductionDecisionV3) error { + if err := d.Validate(); err != nil { + return err + } + if d.Schema != DefinitionSchemaV4 { + return errors.New("decision v3 requires definition v4") + } + if d.Mode != ModeProduction { + return errors.New("production decisions require a production-mode signed definition") + } + if err := decision.Validate(); err != nil { + return err + } + if d.CeremonyID != decision.CeremonyID || *d.AssurancePolicy != *decision.AssurancePolicy { + return errors.New("decision differs from signed ceremony and assurance policy") + } + if d.Software.SourceCommit != decision.SourceRelease.SourceCommit { + return errors.New("decision source commit differs from ceremony") + } + if !equalCircuitBinding(d.Circuit, decision.K21Rehearsal.Circuit) { + return errors.New("K21 rehearsal does not bind the exact ceremony circuit") + } + for _, a := range decision.Auditors { + identity, ok := auditorByID(d, a.AuditorID) + if !ok || identity.KeyID != a.AuditorKeyID { + return errors.New("decision auditor is not in signed ceremony roster") + } + } + for _, a := range decision.ExternalAudits { + fingerprint := a.Auditor.PublicKeyFingerprint + if fingerprint == d.Coordinator.PublicKeyFingerprint || fingerprint == d.ReleaseSigner.PublicKeyFingerprint { + return errors.New("external auditor key must differ from coordinator and release signer") + } + for _, enrolled := range d.Auditors { + if fingerprint == enrolled.PublicKeyFingerprint { + return errors.New("external auditor key must differ from ceremony auditors") + } + } + } + return nil +} + +// This binds reviewed report bytes, not a claim that this program contacted +// GitHub, tested infrastructure, established independence or measured erasure. +// It verifies the complete package without a circuit or replay callback. +func VerifyProductionDecisionEvidenceV4(o VerifyProductionDecisionEvidenceV4Options) (VerifiedProductionDecisionV4, error) { + empty := VerifiedProductionDecisionV4{} + trusted, err := LoadSignedDefinition(o.Trust) + if err != nil { + return empty, err + } + d := trusted.Definition + var decision ProductionDecisionV3 + if len(o.DecisionBytes) > maxSignedRecordBytes { + return empty, errors.New("decision exceeds record size limit") + } + if err := UnmarshalCanonical(o.DecisionBytes, &decision); err != nil { + return empty, err + } + if err := validateProductionDecisionBindingV4(d, decision); err != nil { + return empty, err + } + release, inventory, err := VerifyFinalReleaseCheckpointV4(o.Trust, o.ArtifactRoot, decision.Release.FinalReleaseCheckpoint) + if err != nil { + return empty, fmt.Errorf("final release package: %w", err) + } + if err := validateDecisionPackageBindingV4(d, decision, release); err != nil { + return empty, err + } + packageReader, err := openCheckpointReaderV4(filepath.Join(o.ArtifactRoot, FinalReleasePackagePrefixV4)) + if err != nil { + return empty, err + } + defer func() { _ = packageReader.root.Close() }() + auditors := make([]DecisionAuditorV3, 0, len(release.Transcript.ReleaseReview.Audits)) + for _, pair := range release.Transcript.ReleaseReview.Audits { + raw, _, err := packageReader.pair(pair) + if err != nil { + return empty, err + } + var audit AuditRecord + if err := UnmarshalCanonical(raw, &audit); err != nil { + return empty, err + } + auditors = append(auditors, DecisionAuditorV3{AuditorID: audit.AuditorID, AuditorKeyID: audit.AuditorKeyID}) + } + slices.SortFunc(auditors, func(a, b DecisionAuditorV3) int { return strings.Compare(a.AuditorID, b.AuditorID) }) + if !slices.Equal(auditors, decision.Auditors) { + return empty, errors.New("decision auditor list differs from exact package audits") + } + reader, err := openCheckpointReaderV4(o.ArtifactRoot) + if err != nil { + return empty, err + } + defer func() { _ = reader.root.Close() }() + refs, err := verifyDecisionExternalEvidenceV3(reader, decision) + if err != nil { + return empty, err + } + return VerifiedProductionDecisionV4{Decision: decision, DecisionDigest: NewDigest(o.DecisionBytes), VerifiedSigners: []string{}, VerifiedExternalArtifacts: refs, ReleaseInventory: inventory}, nil +} + +func validateDecisionPackageBindingV4(d CeremonyDefinition, decision ProductionDecisionV3, release *VerifyReleaseResult) error { + if release == nil || release.Candidate.CandidateID != decision.Release.CandidateID { + return errors.New("decision candidate differs from verified release package") + } + definitionBytes, err := MarshalCanonical(d) + if err != nil { + return err + } + if release.Candidate.CeremonyID != d.CeremonyID || release.Candidate.Definition.Digest != NewDigest(definitionBytes) || release.Transcript.CeremonyID != d.CeremonyID || release.Transcript.Definition != release.Candidate.Definition { + return errors.New("verified release package differs from the initially authenticated ceremony definition") + } + at, err := time.Parse(time.RFC3339Nano, decision.DecidedAt) + if err != nil { + return err + } + released, err := time.Parse(time.RFC3339Nano, release.Transcript.FinalizedAt) + if err != nil { + return err + } + if at.Before(released) { + return errors.New("decision predates signed release package") + } + return nil +} + +func verifyDecisionExternalEvidenceV3(reader *checkpointReaderV4, decision ProductionDecisionV3) ([]ArtifactRef, error) { + refs, err := decisionExternalArtifactsV3(decision) + if err != nil { + return nil, err + } + for _, ref := range refs { + if _, err := reader.read(ref, maxSignedRecordBytes, false); err != nil { + return nil, err + } + } + for _, a := range decision.ExternalAudits { + report, err := reader.read(a.Report, maxSignedRecordBytes, true) + if err != nil { + return nil, err + } + raw, err := reader.read(a.Signoff, 4096, true) + if err != nil { + return nil, err + } + var signature DetachedSignature + if err := UnmarshalCanonical(raw, &signature); err != nil { + return nil, err + } + key, err := identityPublicKey(a.Auditor) + if err != nil { + return nil, err + } + if err := VerifyExact(report, signature, a.Auditor.KeyID, key); err != nil { + return nil, err + } + } + return refs, nil +} + +func decisionSignerIdentityV4(d CeremonyDefinition, decision ProductionDecisionV3, role DecisionSignerRole, id string) (Identity, error) { + switch role { + case DecisionSignerCoordinator: + if id == d.Coordinator.ID { + return d.Coordinator, nil + } + case DecisionSignerRelease: + if id == d.ReleaseSigner.ID { + return d.ReleaseSigner, nil + } + case DecisionSignerAuditor: + for _, a := range decision.Auditors { + if a.AuditorID == id { + identity, ok := auditorByID(d, id) + if ok && identity.KeyID == a.AuditorKeyID { + return identity, nil + } + } + } + } + return Identity{}, errors.New("decision signature is outside the exact required signer set") +} + +func requiredDecisionSignersV4(d CeremonyDefinition, decision ProductionDecisionV3) []string { + ids := []string{string(DecisionSignerCoordinator) + "\x00" + d.Coordinator.ID, string(DecisionSignerRelease) + "\x00" + d.ReleaseSigner.ID} + for _, a := range decision.Auditors { + ids = append(ids, string(DecisionSignerAuditor)+"\x00"+a.AuditorID) + } + slices.Sort(ids) + return ids +} + +func SignProductionDecisionV4(o VerifyProductionDecisionEvidenceV4Options, role DecisionSignerRole, id string, key ed25519.PrivateKey) ([]byte, error) { + verified, err := VerifyProductionDecisionEvidenceV4(o) + if err != nil { + return nil, err + } + trusted, err := LoadSignedDefinition(o.Trust) + if err != nil { + return nil, err + } + if err := validateProductionDecisionBindingV4(trusted.Definition, verified.Decision); err != nil { + return nil, err + } + identity, err := decisionSignerIdentityV4(trusted.Definition, verified.Decision, role, id) + if err != nil { + return nil, err + } + public, err := identityPublicKey(identity) + if err != nil { + return nil, err + } + if len(key) != ed25519.PrivateKeySize || !bytes.Equal(key[ed25519.SeedSize:], public) { + return nil, errors.New("decision signing key differs from required identity") + } + sig, err := SignExact(o.DecisionBytes, identity.KeyID, key) + if err != nil { + return nil, err + } + return MarshalCanonical(ProductionDecisionSignature{Schema: ProductionDecisionSignatureSchema, Role: role, SignerID: id, Signature: sig}) +} + +func VerifyProductionDecisionV4(o VerifyProductionDecisionV4Options) (VerifiedProductionDecisionV4, error) { + empty := VerifiedProductionDecisionV4{} + verified, err := VerifyProductionDecisionEvidenceV4(o.VerifyProductionDecisionEvidenceV4Options) + if err != nil { + return empty, err + } + trusted, err := LoadSignedDefinition(o.Trust) + if err != nil { + return empty, err + } + if err := validateProductionDecisionBindingV4(trusted.Definition, verified.Decision); err != nil { + return empty, err + } + verified.VerifiedSigners, err = verifyDecisionSignaturesV4(trusted.Definition, verified.Decision, o.DecisionBytes, o.SignatureBytes) + if err != nil { + return empty, err + } + return verified, nil +} + +func verifyDecisionSignaturesV4(d CeremonyDefinition, decision ProductionDecisionV3, record []byte, signatures [][]byte) ([]string, error) { + verified := []string{} + seen := map[string]bool{} + for _, raw := range signatures { + if len(raw) > 4096 { + return nil, errors.New("decision signature exceeds size limit") + } + var s ProductionDecisionSignature + if err := UnmarshalCanonical(raw, &s); err != nil { + return nil, err + } + identity, err := decisionSignerIdentityV4(d, decision, s.Role, s.SignerID) + if err != nil { + return nil, err + } + id := string(s.Role) + "\x00" + s.SignerID + if seen[id] { + return nil, errors.New("duplicate decision signer") + } + seen[id] = true + public, err := identityPublicKey(identity) + if err != nil { + return nil, err + } + if err := VerifyExact(record, s.Signature, identity.KeyID, public); err != nil { + return nil, err + } + verified = append(verified, id) + } + if len(seen) == 0 { + return nil, errors.New("decision requires at least one authorized signature") + } + slices.Sort(verified) + if decision.Decision == DecisionGO && !slices.Equal(verified, requiredDecisionSignersV4(d, decision)) { + return nil, errors.New("GO requires coordinator, release signer and every package auditor") + } + return verified, nil +} diff --git a/internal/mpcceremony/decision_v3_verify_test.go b/internal/mpcceremony/decision_v3_verify_test.go new file mode 100644 index 00000000..b6141252 --- /dev/null +++ b/internal/mpcceremony/decision_v3_verify_test.go @@ -0,0 +1,284 @@ +package mpcceremony + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func testDecisionSignatureV3(t *testing.T, raw []byte, identity Identity, role DecisionSignerRole, seed byte) []byte { + t.Helper() + sig, err := SignExact(raw, identity.KeyID, adversarialPrivateKey(seed)) + if err != nil { + t.Fatal(err) + } + out, err := MarshalCanonical(ProductionDecisionSignature{Schema: ProductionDecisionSignatureSchema, Role: role, SignerID: identity.ID, Signature: sig}) + if err != nil { + t.Fatal(err) + } + return out +} + +func TestDecisionV3SignatureThresholdAndExactBytes(t *testing.T) { + d, x := decisionFixtureV3(t) + raw, _ := MarshalCanonical(x) + coord := testDecisionSignatureV3(t, raw, d.Coordinator, DecisionSignerCoordinator, 1) + release := testDecisionSignatureV3(t, raw, d.ReleaseSigner, DecisionSignerRelease, 2) + if got, err := verifyDecisionSignaturesV4(d, x, raw, [][]byte{coord, release}); err != nil || len(got) != 2 { + t.Fatalf("threshold: %v", err) + } + for _, sigs := range [][][]byte{nil, {coord}, {release}, {coord, release, coord}} { + if _, err := verifyDecisionSignaturesV4(d, x, raw, sigs); err == nil { + t.Fatal("incomplete/duplicate threshold accepted") + } + } + changed := bytes.Clone(raw) + changed[len(changed)-2] ^= 1 + if _, err := verifyDecisionSignaturesV4(d, x, changed, [][]byte{coord, release}); err == nil { + t.Fatal("signature reused on changed decision") + } + other := adversarialIdentity(t, "outsider", 5) + extra := testDecisionSignatureV3(t, raw, other, DecisionSignerAuditor, 5) + if _, err := verifyDecisionSignaturesV4(d, x, raw, [][]byte{coord, release, extra}); err == nil { + t.Fatal("extra signer accepted") + } + a := adversarialIdentity(t, "auditor", 3) + d.Auditors = []Identity{a} + x.Auditors = []DecisionAuditorV3{{AuditorID: a.ID, AuditorKeyID: a.KeyID}} + // Threshold-only test: the full API additionally validates signed policy and + // re-derives this auditor list from the cryptographically verified package. + if _, err := verifyDecisionSignaturesV4(d, x, raw, [][]byte{coord, release}); err == nil { + t.Fatal("missing auditor consent accepted") + } + audit := testDecisionSignatureV3(t, raw, a, DecisionSignerAuditor, 3) + if got, err := verifyDecisionSignaturesV4(d, x, raw, [][]byte{coord, release, audit}); err != nil || len(got) != 3 { + t.Fatalf("auditor threshold: %v", err) + } +} + +func TestDecisionV3PackageGatesAndExternalBindings(t *testing.T) { + _, base := decisionFixtureV3(t) + clone := func() ProductionDecisionV3 { + raw, _ := json.Marshal(base) + var x ProductionDecisionV3 + _ = json.Unmarshal(raw, &x) + return x + } + for _, gate := range []ProductionGate{GateSignedRelease, GateOperationalEvidence} { + for _, status := range []ProductionGateStatus{GateFAIL, GatePENDING} { + x := clone() + x.Decision = DecisionNOGO + for i := range x.Gates { + if x.Gates[i].Gate == gate { + x.Gates[i].Status = status + x.Gates[i].Rationale = "Contradictory fixture" + } + } + if _, err := NewProductionDecisionV3(x); err == nil { + t.Fatal("contradictory package gate accepted") + } + } + } + for _, gate := range []ProductionGate{GateSourceRelease, GateK21Rehearsal, GateMainnetDeploymentPlan, GateFormalChecklist} { + x := clone() + for i := range x.Gates { + if x.Gates[i].Gate == gate { + x.Gates[i].Evidence = []ArtifactRef{checkpointArtifact("decision/evidence/unrelated.txt", "x")} + } + } + if _, err := NewProductionDecisionV3(x); err == nil { + t.Fatal("gate used unrelated structured evidence") + } + } + x := clone() + x.Decision = DecisionNOGO + for i := range x.Gates { + if x.Gates[i].Gate == GateParticipantHost { + x.Gates[i].Status = GatePENDING + x.Gates[i].Rationale = "Host review incomplete" + } + } + if _, err := NewProductionDecisionV3(x); err != nil { + t.Fatal(err) + } +} + +func TestDecisionV3PackageCandidateAndTimeBinding(t *testing.T) { + d, x := decisionFixtureV3(t) + db, _ := MarshalCanonical(d) + ref := ArtifactRef{Name: "ceremony.json", Digest: NewDigest(db)} + release := &VerifyReleaseResult{Candidate: CandidateMetadata{CandidateID: x.Release.CandidateID, CeremonyID: d.CeremonyID, Definition: ref}, Transcript: FinalTranscript{FinalizedAt: x.DecidedAt, CeremonyID: d.CeremonyID, Definition: ref}} + if err := validateDecisionPackageBindingV4(d, x, release); err != nil { + t.Fatal(err) + } + x.DecidedAt = "2026-07-23T12:00:00Z" + if err := validateDecisionPackageBindingV4(d, x, release); err == nil { + t.Fatal("decision predates release") + } + x.DecidedAt = "2026-07-25T12:00:00Z" + if err := validateDecisionPackageBindingV4(d, x, release); err != nil { + t.Fatal(err) + } + release.Candidate.CandidateID = NewDigest([]byte("other candidate")).SHA256 + if err := validateDecisionPackageBindingV4(d, x, release); err == nil { + t.Fatal("wrong candidate accepted") + } + if err := validateDecisionPackageBindingV4(d, x, nil); err == nil { + t.Fatal("nil verified release accepted") + } + release.Candidate.CandidateID = x.Release.CandidateID + for _, change := range []func(*VerifyReleaseResult){ + func(r *VerifyReleaseResult) { r.Candidate.CeremonyID = NewDigest([]byte("other ceremony")).SHA256 }, + func(r *VerifyReleaseResult) { r.Candidate.Definition.Digest = NewDigest([]byte("other definition")) }, + func(r *VerifyReleaseResult) { r.Transcript.CeremonyID = NewDigest([]byte("other ceremony")).SHA256 }, + func(r *VerifyReleaseResult) { r.Transcript.Definition.Name = "another-definition.json" }, + } { + bad := *release + change(&bad) + if err := validateDecisionPackageBindingV4(d, x, &bad); err == nil { + t.Fatal("mixed ceremony/package accepted") + } + } +} + +func TestDecisionV3RejectsRehearsalAndAbsentPackage(t *testing.T) { + d, x := decisionFixtureV3(t) + rehearsal := d + rehearsal.Mode = ModeRehearsal + var err error + rehearsal, err = FinalizeCeremonyDefinition(rehearsal) + if err != nil { + t.Fatal(err) + } + if err := validateProductionDecisionBindingV4(rehearsal, x); err == nil || !strings.Contains(err.Error(), "production-mode") { + t.Fatalf("rehearsal mode gate: %v", err) + } + root := t.TempDir() + db, ds, err := SignRecord(d, d.Coordinator.KeyID, adversarialPrivateKey(1)) + if err != nil { + t.Fatal(err) + } + trust := TrustPaths{DefinitionPath: filepath.Join(root, "ceremony.json"), DefinitionSignaturePath: filepath.Join(root, "ceremony.sig"), CoordinatorPublicKeyPath: filepath.Join(root, "coordinator.hex")} + if err := os.WriteFile(trust.CoordinatorPublicKeyPath, []byte(d.Coordinator.Ed25519PublicKeyHex+"\n"), 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(trust.DefinitionPath, db, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(trust.DefinitionSignaturePath, ds, 0600); err != nil { + t.Fatal(err) + } + raw, _ := MarshalCanonical(x) + o := VerifyProductionDecisionEvidenceV4Options{Trust: trust, ArtifactRoot: root, DecisionBytes: raw} + draftRaw, err := MarshalCanonical(decisionDraftFixtureV3(x)) + if err != nil { + t.Fatal(err) + } + if _, _, err := PrepareProductionDecisionV4(trust, root, draftRaw); err == nil { + t.Fatal("prepared signable decision without package") + } + if result, err := VerifyProductionDecisionEvidenceV4(o); err == nil || result.Decision.DecisionID != "" { + t.Fatal("absent package yielded evidence result") + } + if _, err := SignProductionDecisionV4(o, DecisionSignerRelease, d.ReleaseSigner.ID, adversarialPrivateKey(2)); err == nil { + t.Fatal("signed without package") + } + if _, err := VerifyProductionDecisionV4(VerifyProductionDecisionV4Options{VerifyProductionDecisionEvidenceV4Options: o, SignatureBytes: [][]byte{testDecisionSignatureV3(t, raw, d.Coordinator, DecisionSignerCoordinator, 1), testDecisionSignatureV3(t, raw, d.ReleaseSigner, DecisionSignerRelease, 2)}}); err == nil { + t.Fatal("signatures substituted for missing package") + } +} + +func TestDecisionV3EnabledExternalGateBindsEveryReportAndSignoff(t *testing.T) { + _, x := decisionFixtureV3(t) + x.AssurancePolicy.ExternalSecurityAuditSignoffs = 1 + x.ExternalAudits = []ExternalAuditEvidenceV3{{Auditor: adversarialIdentity(t, "external", 9), Report: checkpointArtifact("decision/evidence/external.txt", "report"), Signoff: checkpointArtifact("decision/evidence/external.sig", "signature")}} + refs := []ArtifactRef{x.ExternalAudits[0].Report, x.ExternalAudits[0].Signoff} + slices.SortFunc(refs, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + index := -1 + for i := range x.Gates { + if x.Gates[i].Gate == GateExternalAudit { + index = i + x.Gates[i].Status = GatePASS + x.Gates[i].Evidence = refs + x.Gates[i].Rationale = "" + } + } + if _, err := NewProductionDecisionV3(x); err != nil { + t.Fatal(err) + } + x.Gates[index].Evidence = refs[:1] + if _, err := NewProductionDecisionV3(x); err == nil { + t.Fatal("external gate omitted signoff/report") + } +} + +func TestDecisionV3ExternalEvidenceBytesAndSignoff(t *testing.T) { + _, x := decisionFixtureV3(t) + root := t.TempDir() + if err := os.MkdirAll(filepath.Join(root, "decision/evidence"), 0700); err != nil { + t.Fatal(err) + } + refs, err := decisionExternalArtifactsV3(x) + if err != nil { + t.Fatal(err) + } + for _, ref := range refs { + if err := os.WriteFile(filepath.Join(root, ref.Name), []byte("public fixture"), 0600); err != nil { + t.Fatal(err) + } + } + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + defer func() { _ = reader.root.Close() }() + if _, err := verifyDecisionExternalEvidenceV3(reader, x); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, x.SourceRelease.VerificationReport.Name), []byte("changed report"), 0600); err != nil { + t.Fatal(err) + } + if _, err := verifyDecisionExternalEvidenceV3(reader, x); err == nil { + t.Fatal("changed source report accepted") + } + if err := os.WriteFile(filepath.Join(root, x.SourceRelease.VerificationReport.Name), []byte("public fixture"), 0600); err != nil { + t.Fatal(err) + } + a := adversarialIdentity(t, "external", 9) + key := adversarialPrivateKey(9) + report := []byte("external audit public report") + signature, err := SignExact(report, a.KeyID, key) + if err != nil { + t.Fatal(err) + } + sig, err := MarshalCanonical(signature) + if err != nil { + t.Fatal(err) + } + r := checkpointArtifact("decision/evidence/external.txt", string(report)) + s := checkpointArtifact("decision/evidence/external.sig", string(sig)) + if err := os.WriteFile(filepath.Join(root, r.Name), report, 0600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, s.Name), sig, 0600); err != nil { + t.Fatal(err) + } + x.ExternalAudits = []ExternalAuditEvidenceV3{{Auditor: a, Report: r, Signoff: s}} + if _, err := verifyDecisionExternalEvidenceV3(reader, x); err != nil { + t.Fatal(err) + } + // Coherently update the report digest; only cryptographic signature checking + // can reject this mismatch, not an incidental stale artifact hash. + report = []byte("different external report") + x.ExternalAudits[0].Report.Digest = NewDigest(report) + if err := os.WriteFile(filepath.Join(root, r.Name), report, 0600); err != nil { + t.Fatal(err) + } + if _, err := verifyDecisionExternalEvidenceV3(reader, x); err == nil { + t.Fatal("external signature accepted another report") + } +} diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index f4b40d35..db4608d8 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -30,22 +30,35 @@ const ProductionMinimumWitnessLeadSeconds = RecommendedProductionBeaconLeadSecon const ProductionWitnessObservationWindowSeconds uint32 = 60 * 60 type CeremonyDefinition struct { - Schema string `json:"schema"` - CeremonyID string `json:"ceremony_id"` - Mode string `json:"mode"` - CreatedAt string `json:"created_at"` - SessionNonceHex string `json:"session_nonce_hex"` - Circuit CircuitBinding `json:"circuit"` - Software SoftwareBinding `json:"software"` - Coordinator Identity `json:"coordinator"` - ReleaseSigner Identity `json:"release_signer"` - Auditors []Identity `json:"auditors"` - Roster []Participant `json:"roster"` - Phase1Policy PhasePolicy `json:"phase1_policy"` - Phase2Policy PhasePolicy `json:"phase2_policy"` - BeaconPolicy BeaconPolicy `json:"beacon_policy"` - AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` - Phase1Genesis ArtifactRef `json:"phase1_genesis"` + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + CreatedAt string `json:"created_at"` + SessionNonceHex string `json:"session_nonce_hex"` + Circuit CircuitBinding `json:"circuit"` + Software SoftwareBinding `json:"software"` + Coordinator Identity `json:"coordinator"` + ReleaseSigner Identity `json:"release_signer"` + Auditors []Identity `json:"auditors"` + Roster []Participant `json:"roster"` + Phase1Policy PhasePolicy `json:"phase1_policy"` + Phase2Policy PhasePolicy `json:"phase2_policy"` + BeaconPolicy BeaconPolicy `json:"beacon_policy"` + AssurancePolicy *AssurancePolicy `json:"assurance_policy,omitempty"` + ReleaseVerification string `json:"release_verification,omitempty"` + Phase1Genesis ArtifactRef `json:"phase1_genesis"` +} + +// CoordinatorReplayReleaseV1 requires the coordinator's existing full replay. +// The distinct release signer verifies its exact signed result; an independent +// mathematical replay by that signer is optional. This is never inferred from +// absent replay inputs or zero optional-auditor counts. +const CoordinatorReplayReleaseV1 = "coordinator-full-replay-v1" + +// UsesSignedAssurancePolicy selects a versioned capability, not a successful +// verification result. Callers must still authenticate and validate definitions. +func (d CeremonyDefinition) UsesSignedAssurancePolicy() bool { + return d.Schema == DefinitionSchemaV3 || d.Schema == DefinitionSchemaV4 } // AssurancePolicy is the signed, ceremony-wide authority for optional @@ -102,20 +115,21 @@ func cloneAssurancePolicy(policy *AssurancePolicy) *AssurancePolicy { } type DefinitionOptions struct { - Mode string - CreatedAt string - SessionNonceHex string - Circuit CircuitBinding - Software SoftwareBinding - Coordinator Identity - ReleaseSigner Identity - Auditors []Identity - Roster []Participant - Phase1Policy PhasePolicy - Phase2Policy PhasePolicy - BeaconPolicy BeaconPolicy - AssurancePolicy *AssurancePolicy - Phase1Genesis ArtifactRef + Mode string + CreatedAt string + SessionNonceHex string + Circuit CircuitBinding + Software SoftwareBinding + Coordinator Identity + ReleaseSigner Identity + Auditors []Identity + Roster []Participant + Phase1Policy PhasePolicy + Phase2Policy PhasePolicy + BeaconPolicy BeaconPolicy + AssurancePolicy *AssurancePolicy + ReleaseVerification string + Phase1Genesis ArtifactRef } func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error) { @@ -129,21 +143,25 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error assurance = &value } definition := CeremonyDefinition{ - Schema: DefinitionSchema, - Mode: options.Mode, - CreatedAt: options.CreatedAt, - SessionNonceHex: options.SessionNonceHex, - Circuit: options.Circuit, - Software: software, - Coordinator: options.Coordinator, - ReleaseSigner: options.ReleaseSigner, - Auditors: append([]Identity{}, options.Auditors...), - Roster: append([]Participant(nil), options.Roster...), - Phase1Policy: clonePhasePolicy(options.Phase1Policy), - Phase2Policy: clonePhasePolicy(options.Phase2Policy), - BeaconPolicy: options.BeaconPolicy, - AssurancePolicy: assurance, - Phase1Genesis: options.Phase1Genesis, + Schema: DefinitionSchema, + Mode: options.Mode, + CreatedAt: options.CreatedAt, + SessionNonceHex: options.SessionNonceHex, + Circuit: options.Circuit, + Software: software, + Coordinator: options.Coordinator, + ReleaseSigner: options.ReleaseSigner, + Auditors: append([]Identity{}, options.Auditors...), + Roster: append([]Participant(nil), options.Roster...), + Phase1Policy: clonePhasePolicy(options.Phase1Policy), + Phase2Policy: clonePhasePolicy(options.Phase2Policy), + BeaconPolicy: options.BeaconPolicy, + AssurancePolicy: assurance, + ReleaseVerification: options.ReleaseVerification, + Phase1Genesis: options.Phase1Genesis, + } + if options.ReleaseVerification != "" { + definition.Schema = DefinitionSchemaV4 } id, err := ComputeCeremonyID(definition) if err != nil { @@ -160,7 +178,9 @@ func NewCeremonyDefinition(options DefinitionOptions) (CeremonyDefinition, error // content-derived CeremonyID. It is useful to decouple expensive circuit // compilation from metadata construction. func FinalizeCeremonyDefinition(definition CeremonyDefinition) (CeremonyDefinition, error) { - definition.Schema = DefinitionSchema + if definition.Schema != DefinitionSchemaV4 { + definition.Schema = DefinitionSchema + } if definition.Auditors == nil { definition.Auditors = []Identity{} } @@ -196,6 +216,8 @@ func ComputeCeremonyID(definition CeremonyDefinition) (string, error) { domain = "proof-tool/mpc-ceremony/root/v1" case DefinitionSchemaV2: domain = "proof-tool/mpc-ceremony/root/v2" + case DefinitionSchemaV4: + domain = "proof-tool/mpc-ceremony/root/v4" } return canonicalHash(domain, definition) } @@ -215,8 +237,15 @@ func (d CeremonyDefinition) Validate() error { } func (d CeremonyDefinition) validate(requireID bool) error { + if d.Schema == DefinitionSchemaV4 { + if d.ReleaseVerification != CoordinatorReplayReleaseV1 { + return errors.New("definition v4 requires explicit coordinator-full-replay-v1 release verification") + } + } else if d.ReleaseVerification != "" { + return errors.New("release_verification is only permitted in definition v4") + } switch d.Schema { - case DefinitionSchema: + case DefinitionSchemaV3, DefinitionSchemaV4: case DefinitionSchemaV2: if d.AssurancePolicy != nil { return errors.New("definition v2 must not contain v3-only assurance_policy") @@ -227,8 +256,8 @@ func (d CeremonyDefinition) validate(requireID bool) error { } default: return fmt.Errorf( - "definition schema %q, want %q, %q or %q", - d.Schema, DefinitionSchemaV1, DefinitionSchemaV2, DefinitionSchema, + "unsupported definition schema %q", + d.Schema, ) } if requireID { @@ -291,8 +320,8 @@ func (d CeremonyDefinition) validate(requireID bool) error { if err := d.Software.Validate(); err != nil { return fmt.Errorf("software: %w", err) } - if (d.Schema == DefinitionSchema || d.Schema == DefinitionSchemaV2) && len(d.Software.Binaries) == 0 { - return errors.New("definition v2 or v3 requires at least one allowed software binary") + if (d.UsesSignedAssurancePolicy() || d.Schema == DefinitionSchemaV2) && len(d.Software.Binaries) == 0 { + return errors.New("definition requires at least one allowed software binary") } if d.Mode == ModeProduction { for index, binary := range d.Software.AllowedBinaries() { @@ -320,8 +349,8 @@ func (d CeremonyDefinition) validate(requireID bool) error { if d.ReleaseSigner.ID == d.Coordinator.ID || d.ReleaseSigner.KeyID == d.Coordinator.KeyID { return errors.New("release signer must be distinct from coordinator") } - if d.Schema == DefinitionSchema && d.Auditors == nil { - return errors.New("definition v3 requires an explicit auditors array; use [] when audits are disabled") + if d.UsesSignedAssurancePolicy() && d.Auditors == nil { + return errors.New("definition requires an explicit auditors array; use [] when audits are disabled") } if len(d.Auditors) > MaxAuditors { return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) @@ -358,9 +387,9 @@ func (d CeremonyDefinition) validate(requireID bool) error { keyIDs[auditor.KeyID] = "auditor" publicKeyFingerprints[auditor.PublicKeyFingerprint] = "auditor" } - if d.Schema == DefinitionSchema { + if d.UsesSignedAssurancePolicy() { if d.AssurancePolicy == nil { - return errors.New("definition v3 requires assurance_policy; omission does not disable controls") + return errors.New("definition requires assurance_policy; omission does not disable controls") } if err := d.AssurancePolicy.Validate(d.Mode, len(d.Auditors)); err != nil { return fmt.Errorf("assurance_policy: %w", err) diff --git a/internal/mpcceremony/definition_v4_test.go b/internal/mpcceremony/definition_v4_test.go new file mode 100644 index 00000000..c32bcaa7 --- /dev/null +++ b/internal/mpcceremony/definition_v4_test.go @@ -0,0 +1,153 @@ +package mpcceremony + +import ( + "bytes" + "strings" + "testing" +) + +func trustedCoordinatorDefinition(t *testing.T) CeremonyDefinition { + t.Helper() + d := adversarialDefinition(t) + d.Schema = DefinitionSchemaV4 + d.ReleaseVerification = CoordinatorReplayReleaseV1 + d, err := FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + return d +} + +func TestDefinitionV4ExplicitTrustPolicyAndDistinctIdentity(t *testing.T) { + legacy := adversarialDefinition(t) + if legacy.Schema != DefinitionSchemaV3 { + t.Fatal("default changed before new workflow is complete") + } + d := legacy + d.Schema = DefinitionSchemaV4 + d.ReleaseVerification = CoordinatorReplayReleaseV1 + d, err := FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + if d.CeremonyID == legacy.CeremonyID { + t.Fatal("changed trust model reused ceremony identity") + } + if err := d.Validate(); err != nil { + t.Fatal(err) + } + for _, schema := range []string{DefinitionSchemaV1, DefinitionSchemaV2, DefinitionSchemaV3} { + changed := d + changed.Schema = schema + if _, err := ComputeCeremonyID(changed); err == nil { + t.Fatalf("%s accepted v4 policy", schema) + } + } + for _, policy := range []string{"", "none", "coordinator-said-so", "signer-optional"} { + changed := d + changed.ReleaseVerification = policy + if _, err := ComputeCeremonyID(changed); err == nil { + t.Fatalf("policy %q accepted", policy) + } + } + legacyBytes, err := MarshalCanonical(legacy) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(legacyBytes, []byte("release_verification")) { + t.Fatal("legacy signed bytes gained new field") + } + var decoded CeremonyDefinition + if err := UnmarshalCanonical(legacyBytes, &decoded); err != nil { + t.Fatal(err) + } + if err := decoded.Validate(); err != nil { + t.Fatal(err) + } + for _, extra := range []string{`""`, `null`, `"coordinator-full-replay-v1"`} { + raw := append(bytes.Clone(legacyBytes[:len(legacyBytes)-1]), []byte(",\"release_verification\":"+extra+"}")...) + if err := UnmarshalCanonical(raw, &decoded); err == nil { + t.Fatal("legacy format accepted a new-version field") + } + } +} + +func TestDefinitionV4PreservesRequiredSignerAndExplicitAssurance(t *testing.T) { + d := trustedCoordinatorDefinition(t) + d.Auditors = []Identity{} + d.AssurancePolicy = &AssurancePolicy{} + d, err := FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + missing := d + missing.AssurancePolicy = nil + if _, err := ComputeCeremonyID(missing); err == nil { + t.Fatal("missing assurance treated as zero") + } + missing = d + missing.Auditors = nil + if _, err := ComputeCeremonyID(missing); err == nil { + t.Fatal("missing auditors treated as empty") + } + missing = d + missing.ReleaseSigner = Identity{} + if _, err := ComputeCeremonyID(missing); err == nil { + t.Fatal("missing release signer accepted") + } + missing = d + missing.ReleaseSigner = d.Coordinator + if _, err := ComputeCeremonyID(missing); err == nil { + t.Fatal("coordinator reused as release signer") + } + // The new declaration alone cannot take the old signer path. + if err := verifyRequiredReleaseSignerReplay(d.Schema, SignReleaseOptions{}); err == nil || !strings.Contains(err.Error(), "unsupported") { + t.Fatalf("v4 fell through into the legacy signing path: %v", err) + } + if err := validateCheckpointDefinitionVersion(d, Checkpoint{Schema: CheckpointSchemaV1}); err == nil { + t.Fatal("v4 fell through to legacy checkpoints") + } + if err := validateProductionDecisionBinding(d, ProductionDecision{Schema: ProductionDecisionSchemaV1, CeremonyID: d.CeremonyID}); err == nil { + t.Fatal("v4 fell through to legacy production decisions") + } + if expectedFinalTranscriptSchema(d) != FinalTranscriptSchemaV3 { + t.Fatal("v4 selected a legacy transcript") + } +} + +func TestDefinitionConstructorExplicitReleaseVerification(t *testing.T) { + d := adversarialDefinition(t) + opts := DefinitionOptions{Mode: d.Mode, CreatedAt: d.CreatedAt, SessionNonceHex: d.SessionNonceHex, + Circuit: d.Circuit, Software: d.Software, Coordinator: d.Coordinator, ReleaseSigner: d.ReleaseSigner, + Auditors: d.Auditors, Roster: d.Roster, Phase1Policy: d.Phase1Policy, Phase2Policy: d.Phase2Policy, + BeaconPolicy: d.BeaconPolicy, AssurancePolicy: d.AssurancePolicy, Phase1Genesis: d.Phase1Genesis} + old, err := NewCeremonyDefinition(opts) + if err != nil { + t.Fatal(err) + } + want, err := MarshalCanonical(d) + if err != nil { + t.Fatal(err) + } + got, err := MarshalCanonical(old) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(want, got) { + t.Fatal("default constructor changed released V3 bytes") + } + opts.ReleaseVerification = CoordinatorReplayReleaseV1 + current, err := NewCeremonyDefinition(opts) + if err != nil { + t.Fatal(err) + } + if current.Schema != DefinitionSchemaV4 || current.CeremonyID == old.CeremonyID { + t.Fatal("explicit policy did not select distinct V4 ceremony") + } + for _, value := range []string{"none", " ", "coordinator-full-replay-v2"} { + opts.ReleaseVerification = value + if _, err := NewCeremonyDefinition(opts); err == nil { + t.Fatalf("unknown release policy %q accepted", value) + } + } +} diff --git a/internal/mpcceremony/delivery_scope.go b/internal/mpcceremony/delivery_scope.go new file mode 100644 index 00000000..4752f538 --- /dev/null +++ b/internal/mpcceremony/delivery_scope.go @@ -0,0 +1,296 @@ +package mpcceremony + +import ( + "errors" + "fmt" +) + +// ContributionScope identifies protocol work, independently of how its files +// are delivered. No storage location, launcher release, or delivery attempt is +// part of a participant's existing signed contribution. +type ContributionScope struct { + CeremonyID string `json:"ceremony_id"` + Phase Phase `json:"phase"` + Index uint8 `json:"index"` + ParticipantID string `json:"participant_id"` + ParentHeadID string `json:"parent_head_id"` +} + +func (s ContributionScope) Validate() error { + if err := validateHashID("ceremony_id", s.CeremonyID); err != nil { + return err + } + if err := s.Phase.Validate(); err != nil { + return err + } + if s.Index == 0 || s.Index > MaxParticipants { + return errors.New("contribution scope requires a scheduled nonzero index") + } + if err := validateID("participant_id", s.ParticipantID); err != nil { + return err + } + return validateHashID("parent_head_id", s.ParentHeadID) +} + +// ValidateAssignment checks the frozen schedule, not whether this is the +// current turn. Checkpoint verification checks the authenticated current head. +func (s ContributionScope) ValidateAssignment(d CeremonyDefinition) error { + if err := d.Validate(); err != nil { + return err + } + if err := s.Validate(); err != nil { + return err + } + if s.CeremonyID != d.CeremonyID { + return errors.New("contribution scope belongs to another ceremony") + } + policy := d.Phase1Policy + if s.Phase == Phase2 { + policy = d.Phase2Policy + } + if int(s.Index) > len(policy.Participants) || policy.Participants[int(s.Index)-1] != s.ParticipantID { + return errors.New("contribution scope does not match the signed participant order") + } + return nil +} + +const CandidateInventorySchemaV1 = "proof-tool-mpc-candidate-inventory-v1" + +// CandidateInventory is a closed description of all submitted candidate bytes. +// Names are protocol-local basenames, not object-store keys or host paths. +// It is not another participant envelope and is not separately signed. Its +// domain-separated ID lets a coordinator checkpoint reject these exact bytes +// even when they are delivered again using a different attempt. +type CandidateInventory struct { + Schema string `json:"schema"` + Scope ContributionScope `json:"scope"` + Files []ArtifactRef `json:"files"` +} + +func (c CandidateInventory) Validate() error { + if c.Schema != CandidateInventorySchemaV1 { + return errors.New("unsupported candidate inventory schema") + } + if err := c.Scope.Validate(); err != nil { + return err + } + expected := []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} + if len(c.Files) != len(expected) { + return errors.New("candidate requires exactly the contribution, signed attestation and signed cleanup files") + } + for i, ref := range c.Files { + if err := ref.Validate(); err != nil { + return fmt.Errorf("candidate file %d: %w", i, err) + } + if ref.Name != expected[i] { + return fmt.Errorf("candidate file %d must be %s", i, expected[i]) + } + var limit int64 + switch ref.Name { + case "contribution.bin": + limit = MaxArtifactSize + case "attestation.sig", "erasure.sig": + limit = 4096 + default: + limit = maxSignedRecordBytes + } + if ref.Digest.Size <= 0 || ref.Digest.Size > limit { + return fmt.Errorf("candidate file %s exceeds its protocol size bound", ref.Name) + } + } + return nil +} + +// ID identifies bytes, not validity. Signature/cleanup/math verification and +// applicable return-custody requirements remain separate acceptance checks. +// Changed bytes produce a different candidate and require fresh verification. +func (c CandidateInventory) ID() (string, error) { + if err := c.Validate(); err != nil { + return "", err + } + return canonicalHash("proof-tool/mpc-candidate-inventory/v1", c) +} + +type DeliveryStatus string + +const ( + DeliveryAllocated DeliveryStatus = "allocated" + DeliveryAccepted DeliveryStatus = "accepted" + DeliveryRetired DeliveryStatus = "retired" + DeliveryRejected DeliveryStatus = "rejected" + // Bounds include successful attempts. They limit the signed state size and + // require an explicit future format change rather than unbounded retry history. + MaxDeliverySlotsV2 = 4096 + MaxDeliveryAttemptsPerSubmissionV2 = 16 +) + +// DeliverySlotV2 is coordinator-authored tracking, not a claim that the +// participant approved a particular attempt ID. A retired delivery may carry +// the same valid bytes in a replacement slot; a rejected candidate may not. +type DeliverySlotV2 struct { + Scope ContributionScope `json:"scope"` + Kind CheckpointSubmissionKind `json:"kind"` + AttemptID string `json:"attempt_id"` + Status DeliveryStatus `json:"status"` + ContributionResultID string `json:"contribution_result_id,omitempty"` +} + +func (s DeliverySlotV2) Validate() error { + if err := s.Scope.Validate(); err != nil { + return err + } + if s.Kind != CheckpointSubmissionReceipt && s.Kind != CheckpointSubmissionCandidate { + return errors.New("unsupported delivery kind") + } + if err := validateHex(s.AttemptID, 16); err != nil { + return fmt.Errorf("delivery attempt: %w", err) + } + switch s.Status { + case DeliveryAllocated, DeliveryRetired: + if s.ContributionResultID != "" { + return errors.New("unaccepted delivery must not assert a candidate disposition") + } + case DeliveryAccepted: + if s.Kind == CheckpointSubmissionReceipt { + if s.ContributionResultID != "" { + return errors.New("receipt delivery must not identify a candidate") + } + return nil + } + return validateHashID("contribution_result_id", s.ContributionResultID) + case DeliveryRejected: + if s.Kind != CheckpointSubmissionCandidate { + return errors.New("retire an invalid receipt delivery; candidate rejection is only for candidate bytes") + } + return validateHashID("contribution_result_id", s.ContributionResultID) + default: + return errors.New("unsupported delivery status") + } + return nil +} + +// ValidateDeliveryHistoryV2 checks the entire retained allocation history. +// Entries stay in allocation order; terminal dispositions are never discarded. +// Only AdvanceDeliveryV2 may change a currently allocated entry during a +// checkpoint transition. This function alone does not authenticate a history. +func ValidateDeliveryHistoryV2(slots []DeliverySlotV2) error { + if slots == nil || len(slots) > MaxDeliverySlotsV2 { + return errors.New("delivery history requires an explicit array within the protocol limit") + } + type group struct { + scope ContributionScope + count, active, accepted int + } + type groupKey struct { + CeremonyID string + Phase Phase + Index uint8 + ParticipantID string + Kind CheckpointSubmissionKind + } + groups := map[groupKey]*group{} + attempts := map[string]bool{} + rejected := map[string]bool{} + accepted := map[string]bool{} + for _, s := range slots { + if err := s.Validate(); err != nil { + return err + } + if attempts[s.AttemptID] { + return errors.New("delivery attempt IDs must be globally unique") + } + attempts[s.AttemptID] = true + key := groupKey{s.Scope.CeremonyID, s.Scope.Phase, s.Scope.Index, s.Scope.ParticipantID, s.Kind} + g := groups[key] + if g == nil { + g = &group{scope: s.Scope} + groups[key] = g + } + if g.scope != s.Scope { + return errors.New("replacement delivery changed its predecessor") + } + g.count++ + if g.count > MaxDeliveryAttemptsPerSubmissionV2 { + return errors.New("delivery retry limit reached for this submission") + } + switch s.Status { + case DeliveryAllocated: + g.active++ + case DeliveryAccepted: + g.accepted++ + if s.ContributionResultID != "" { + accepted[s.ContributionResultID] = true + } + case DeliveryRejected: + rejected[s.ContributionResultID] = true + } + if g.active+g.accepted > 1 { + return errors.New("a submission may have only one active allocation or one terminal acceptance") + } + } + for id := range accepted { + if rejected[id] { + return errors.New("a rejected contribution result cannot be accepted through another delivery") + } + } + return nil +} + +// AllocateDeliveryV2 creates one fresh transport attempt. The checkpoint layer +// must additionally require the current scheduled turn and prerequisite record. +// No participant signature is requested for a delivery attempt. +func AllocateDeliveryV2(previous []DeliverySlotV2, scope ContributionScope, kind CheckpointSubmissionKind, attemptID string) ([]DeliverySlotV2, error) { + if err := ValidateDeliveryHistoryV2(previous); err != nil { + return nil, err + } + next := append(append([]DeliverySlotV2{}, previous...), DeliverySlotV2{Scope: scope, Kind: kind, AttemptID: attemptID, Status: DeliveryAllocated}) + if err := ValidateDeliveryHistoryV2(next); err != nil { + return nil, err + } + return next, nil +} + +// AdvanceDeliveryV2 records a disposition only for an active allocation. A +// rejected result remains in history. Retirement is for delivery problems and +// deliberately records no result ID. Inventory describes exact complete bytes; +// acceptance still requires the protocol/math verification performed by its +// checkpoint authoring command. +func AdvanceDeliveryV2(previous []DeliverySlotV2, attemptID string, status DeliveryStatus, inventory *CandidateInventory) ([]DeliverySlotV2, error) { + if err := ValidateDeliveryHistoryV2(previous); err != nil { + return nil, err + } + if status != DeliveryAccepted && status != DeliveryRetired && status != DeliveryRejected { + return nil, errors.New("delivery transition requires a terminal disposition") + } + next := append([]DeliverySlotV2{}, previous...) + index := -1 + for i, s := range previous { + if s.AttemptID == attemptID { + index = i + break + } + } + if index == -1 || next[index].Status != DeliveryAllocated { + return nil, errors.New("only a currently allocated delivery may change") + } + slot := &next[index] + needsInventory := slot.Kind == CheckpointSubmissionCandidate && (status == DeliveryAccepted || status == DeliveryRejected) + if needsInventory != (inventory != nil) { + return nil, errors.New("candidate disposition requires its exact inventory; other delivery changes must not include one") + } + if inventory != nil { + if inventory.Scope != slot.Scope { + return nil, errors.New("candidate inventory does not match delivery scope") + } + id, err := inventory.ID() + if err != nil { + return nil, err + } + slot.ContributionResultID = id + } + slot.Status = status + if err := ValidateDeliveryHistoryV2(next); err != nil { + return nil, err + } + return next, nil +} diff --git a/internal/mpcceremony/delivery_scope_test.go b/internal/mpcceremony/delivery_scope_test.go new file mode 100644 index 00000000..be209716 --- /dev/null +++ b/internal/mpcceremony/delivery_scope_test.go @@ -0,0 +1,263 @@ +package mpcceremony + +import ( + "encoding/json" + "fmt" + "strings" + "testing" +) + +func inventoryTestRef(name string, value []byte) ArtifactRef { + return ArtifactRef{Name: name, Digest: NewDigest(value)} +} + +func candidateInventoryFixture(t *testing.T) (CeremonyDefinition, CandidateInventory) { + t.Helper() + d := trustedCoordinatorDefinition(t) + c := CandidateInventory{Schema: CandidateInventorySchemaV1, + Scope: ContributionScope{CeremonyID: d.CeremonyID, Phase: Phase1, Index: 1, + ParticipantID: d.Phase1Policy.Participants[0], ParentHeadID: NewDigest([]byte("genesis record")).SHA256}} + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} { + c.Files = append(c.Files, inventoryTestRef(name, []byte("synthetic bytes for "+name))) + } + return d, c +} + +func TestContributionResultIDBindsCompleteScopedInventory(t *testing.T) { + d, original := candidateInventoryFixture(t) + if err := original.Scope.ValidateAssignment(d); err != nil { + t.Fatal(err) + } + want, err := original.ID() + if err != nil { + t.Fatal(err) + } + for i := range original.Files { + changed := original + changed.Files = append([]ArtifactRef{}, original.Files...) + changed.Files[i].Digest = NewDigest([]byte("changed exact bytes")) + got, err := changed.ID() + if err != nil || got == want { + t.Fatalf("file %s not bound: %s %v", original.Files[i].Name, got, err) + } + } + for name, change := range map[string]func(*CandidateInventory){ + "ceremony": func(c *CandidateInventory) { c.Scope.CeremonyID = NewDigest([]byte("another ceremony")).SHA256 }, + "phase": func(c *CandidateInventory) { c.Scope.Phase = Phase2 }, + "index": func(c *CandidateInventory) { c.Scope.Index = 2 }, + "participant": func(c *CandidateInventory) { c.Scope.ParticipantID = "another-participant" }, + "head": func(c *CandidateInventory) { c.Scope.ParentHeadID = NewDigest([]byte("another head")).SHA256 }, + } { + t.Run(name, func(t *testing.T) { + changed := original + change(&changed) + got, err := changed.ID() + if err != nil || got == want { + t.Fatalf("scope not bound: %s %v", got, err) + } + }) + } + withReturn := original + withReturn.Files = append(append([]ArtifactRef{}, original.Files...), + inventoryTestRef("return-handoff.json", []byte("return record")), inventoryTestRef("return-handoff.sig", []byte("return signature"))) + if got, err := withReturn.ID(); err == nil { + t.Fatalf("unexpected custody files accepted: %s", got) + } + // Delivery metadata cannot become part of the semantic identity. + for _, attempt := range []string{strings.Repeat("1", 32), strings.Repeat("2", 32)} { + slot := DeliverySlotV2{Scope: original.Scope, Kind: CheckpointSubmissionCandidate, + AttemptID: attempt, Status: DeliveryAllocated} + if err := slot.Validate(); err != nil { + t.Fatal(err) + } + got, err := original.ID() + if err != nil || got != want { + t.Fatal("redelivery changed result identity") + } + } +} + +func TestContributionInventoryRejectsOpenOrPathBasedSets(t *testing.T) { + _, original := candidateInventoryFixture(t) + for name, change := range map[string]func(*CandidateInventory){ + "missing file": func(c *CandidateInventory) { c.Files = c.Files[:4] }, + "extra file": func(c *CandidateInventory) { c.Files = append(c.Files, inventoryTestRef("manifest.json", []byte("x"))) }, + "attempt path": func(c *CandidateInventory) { c.Files[0].Name = "attempt-1/attestation.json" }, + "duplicate": func(c *CandidateInventory) { c.Files[1] = c.Files[0] }, + "permuted": func(c *CandidateInventory) { c.Files[0], c.Files[1] = c.Files[1], c.Files[0] }, + "unpaired return": func(c *CandidateInventory) { + c.Files = append(c.Files, inventoryTestRef("return-handoff.json", []byte("x"))) + }, + "signature bound": func(c *CandidateInventory) { c.Files[1].Digest.Size = 4097 }, + "empty contribution": func(c *CandidateInventory) { c.Files[2].Digest.Size = 0 }, + "unknown schema": func(c *CandidateInventory) { c.Schema = "future" }, + "zero turn": func(c *CandidateInventory) { c.Scope.Index = 0 }, + } { + t.Run(name, func(t *testing.T) { + changed := original + changed.Files = append([]ArtifactRef{}, original.Files...) + change(&changed) + if _, err := changed.ID(); err == nil { + t.Fatal("invalid inventory accepted") + } + }) + } + encoded, err := MarshalCanonical(original) + if err != nil { + t.Fatal(err) + } + var raw map[string]json.RawMessage + if err := json.Unmarshal(encoded, &raw); err != nil { + t.Fatal(err) + } + raw["attempt_id"] = json.RawMessage(`"11111111111111111111111111111111"`) + encoded, err = json.Marshal(raw) + if err != nil { + t.Fatal(err) + } + var parsed CandidateInventory + if err := UnmarshalCanonical(encoded, &parsed); err == nil { + t.Fatal("attempt-dependent inventory accepted") + } +} + +func TestDeliveryDispositionCannotConfuseRetirementAndRejection(t *testing.T) { + _, c := candidateInventoryFixture(t) + id, err := c.ID() + if err != nil { + t.Fatal(err) + } + for _, kind := range []CheckpointSubmissionKind{CheckpointSubmissionReceipt, CheckpointSubmissionCandidate} { + for _, status := range []DeliveryStatus{DeliveryAllocated, DeliveryRetired, DeliveryAccepted, DeliveryRejected, "unknown"} { + for _, result := range []string{"", id} { + slot := DeliverySlotV2{Scope: c.Scope, Kind: kind, AttemptID: strings.Repeat("1", 32), Status: status, ContributionResultID: result} + valid := (status == DeliveryAllocated || status == DeliveryRetired) && result == "" || + status == DeliveryAccepted && ((kind == CheckpointSubmissionReceipt && result == "") || (kind == CheckpointSubmissionCandidate && result == id)) || + status == DeliveryRejected && kind == CheckpointSubmissionCandidate && result == id + if got := slot.Validate(); (got == nil) != valid { + t.Fatalf("kind=%s status=%s result=%q: %v", kind, status, result, got) + } + } + } + } +} + +func TestDeliveryRetirementAllowsRedeliveryButRejectionPersists(t *testing.T) { + _, c := candidateInventoryFixture(t) + first := strings.Repeat("1", 32) + second := strings.Repeat("2", 32) + for _, disposition := range []DeliveryStatus{DeliveryRetired, DeliveryRejected} { + t.Run(string(disposition), func(t *testing.T) { + slots, err := AllocateDeliveryV2([]DeliverySlotV2{}, c.Scope, CheckpointSubmissionCandidate, first) + if err != nil { + t.Fatal(err) + } + if _, err := AllocateDeliveryV2(slots, c.Scope, CheckpointSubmissionCandidate, second); err == nil { + t.Fatal("parallel active allocation accepted") + } + var inventory *CandidateInventory + if disposition == DeliveryRejected { + inventory = &c + } + slots, err = AdvanceDeliveryV2(slots, first, disposition, inventory) + if err != nil { + t.Fatal(err) + } + if _, err := AdvanceDeliveryV2(slots, first, DeliveryAccepted, &c); err == nil { + t.Fatal("terminal disposition rewritten") + } + if _, err := AllocateDeliveryV2(slots, c.Scope, CheckpointSubmissionCandidate, first); err == nil { + t.Fatal("attempt ID reused") + } + slots, err = AllocateDeliveryV2(slots, c.Scope, CheckpointSubmissionCandidate, second) + if err != nil { + t.Fatal(err) + } + accepted, err := AdvanceDeliveryV2(slots, second, DeliveryAccepted, &c) + if disposition == DeliveryRejected { + if err == nil { + t.Fatal("rejected bytes accepted by redelivery") + } + corrected := c + corrected.Files = append([]ArtifactRef{}, c.Files...) + corrected.Files[0].Digest = NewDigest([]byte("different complete candidate")) + accepted, err = AdvanceDeliveryV2(slots, second, DeliveryAccepted, &corrected) + } + if err != nil { + t.Fatal(err) + } + if _, err := AllocateDeliveryV2(accepted, c.Scope, CheckpointSubmissionCandidate, strings.Repeat("3", 32)); err == nil { + t.Fatal("accepted submission reopened") + } + if slots[1].Status != DeliveryAllocated { + t.Fatal("input history mutated") + } + }) + } +} + +func TestDeliveryHistoryBoundsAndScope(t *testing.T) { + _, c := candidateInventoryFixture(t) + var err error + slots := []DeliverySlotV2{} + for i := 0; i < MaxDeliveryAttemptsPerSubmissionV2; i++ { + id := fmt.Sprintf("%032x", i) + slots, err = AllocateDeliveryV2(slots, c.Scope, CheckpointSubmissionCandidate, id) + if err != nil { + t.Fatal(err) + } + slots, err = AdvanceDeliveryV2(slots, id, DeliveryRetired, nil) + if err != nil { + t.Fatal(err) + } + } + if _, err := AllocateDeliveryV2(slots, c.Scope, CheckpointSubmissionCandidate, strings.Repeat("f", 32)); err == nil { + t.Fatal("unbounded retry accepted") + } + if err := ValidateDeliveryHistoryV2(nil); err == nil { + t.Fatal("missing history accepted") + } + if err := ValidateDeliveryHistoryV2(make([]DeliverySlotV2, MaxDeliverySlotsV2+1)); err == nil { + t.Fatal("unbounded history accepted") + } + slots = slots[:1] + wrong := c.Scope + wrong.ParentHeadID = NewDigest([]byte("other predecessor")).SHA256 + if _, err := AllocateDeliveryV2(slots, wrong, CheckpointSubmissionCandidate, strings.Repeat("f", 32)); err == nil { + t.Fatal("replacement changed predecessor") + } + slots, err = AllocateDeliveryV2(slots, c.Scope, CheckpointSubmissionCandidate, strings.Repeat("f", 32)) + if err != nil { + t.Fatal(err) + } + changed := c + changed.Scope = wrong + if _, err := AdvanceDeliveryV2(slots, strings.Repeat("f", 32), DeliveryRejected, &changed); err == nil { + t.Fatal("rejection bound another scope") + } +} + +func TestGlobalDeliveryBudgetStillPermitsTerminalRetirement(t *testing.T) { + _, c := candidateInventoryFixture(t) + slots := make([]DeliverySlotV2, MaxDeliverySlotsV2) + for i := range slots { + scope := c.Scope + scope.ParticipantID = fmt.Sprintf("participant-%d", i/MaxDeliveryAttemptsPerSubmissionV2) + slots[i] = DeliverySlotV2{Scope: scope, Kind: CheckpointSubmissionReceipt, AttemptID: fmt.Sprintf("%032x", i), Status: DeliveryRetired} + } + last := len(slots) - 1 + slots[last].Status = DeliveryAllocated + if err := ValidateDeliveryHistoryV2(slots); err != nil { + t.Fatal(err) + } + done, err := AdvanceDeliveryV2(slots, slots[last].AttemptID, DeliveryRetired, nil) + if err != nil { + t.Fatal(err) + } + if done[last].Status != DeliveryRetired { + t.Fatal("last slot cannot retire") + } + if _, err := AllocateDeliveryV2(done, c.Scope, CheckpointSubmissionCandidate, strings.Repeat("f", 32)); err == nil { + t.Fatal("global budget exceeded") + } +} diff --git a/internal/mpcceremony/files.go b/internal/mpcceremony/files.go index 7e9294ae..ba9335a1 100644 --- a/internal/mpcceremony/files.go +++ b/internal/mpcceremony/files.go @@ -27,18 +27,18 @@ func ReadPhase1File(path string, shape Phase1Shape) (*gnarkmpc.Phase1, ArtifactD digest, err := PreflightPhase1(io.NewSectionReader(f, 0, expected), shape) if err != nil { - return nil, ArtifactDigest{}, fmt.Errorf("preflight Phase 1 %q: %w", path, err) + return nil, ArtifactDigest{}, candidateArtifactContent(fmt.Errorf("preflight Phase 1 %q: %w", path, err)) } var artifact gnarkmpc.Phase1 if err := nativeReadExact(io.NewSectionReader(f, 0, expected), expected, &artifact); err != nil { - return nil, ArtifactDigest{}, fmt.Errorf("decode Phase 1 %q: %w", path, err) + return nil, ArtifactDigest{}, candidateArtifactContent(fmt.Errorf("decode Phase 1 %q: %w", path, err)) } if len(artifact.Challenge) != int(shape.ChallengeLength) { - return nil, ArtifactDigest{}, fmt.Errorf("%w: decoded Phase 1 challenge length %d, expected %d", ErrInvalidShape, len(artifact.Challenge), shape.ChallengeLength) + return nil, ArtifactDigest{}, candidateArtifactContent(fmt.Errorf("%w: decoded Phase 1 challenge length %d, expected %d", ErrInvalidShape, len(artifact.Challenge), shape.ChallengeLength)) } if err := requireCanonicalRoundTrip(&artifact, digest); err != nil { - return nil, ArtifactDigest{}, fmt.Errorf("canonical Phase 1 %q: %w", path, err) + return nil, ArtifactDigest{}, candidateArtifactContent(fmt.Errorf("canonical Phase 1 %q: %w", path, err)) } return &artifact, digest, nil } @@ -84,18 +84,18 @@ func ReadPhase2File(path string, shape Phase2Shape) (*gnarkmpc.Phase2, ArtifactD digest, err := PreflightPhase2(io.NewSectionReader(f, 0, expected), shape) if err != nil { - return nil, ArtifactDigest{}, fmt.Errorf("preflight Phase 2 %q: %w", path, err) + return nil, ArtifactDigest{}, candidateArtifactContent(fmt.Errorf("preflight Phase 2 %q: %w", path, err)) } var artifact gnarkmpc.Phase2 if err := nativeReadExact(io.NewSectionReader(f, 0, expected), expected, &artifact); err != nil { - return nil, ArtifactDigest{}, fmt.Errorf("decode Phase 2 %q: %w", path, err) + return nil, ArtifactDigest{}, candidateArtifactContent(fmt.Errorf("decode Phase 2 %q: %w", path, err)) } if err := validateDecodedPhase2(&artifact, shape); err != nil { - return nil, ArtifactDigest{}, err + return nil, ArtifactDigest{}, candidateArtifactContent(err) } if err := requireCanonicalRoundTrip(&artifact, digest); err != nil { - return nil, ArtifactDigest{}, fmt.Errorf("canonical Phase 2 %q: %w", path, err) + return nil, ArtifactDigest{}, candidateArtifactContent(fmt.Errorf("canonical Phase 2 %q: %w", path, err)) } return &artifact, digest, nil } diff --git a/internal/mpcceremony/final_transcript_v3.go b/internal/mpcceremony/final_transcript_v3.go new file mode 100644 index 00000000..07772abb --- /dev/null +++ b/internal/mpcceremony/final_transcript_v3.go @@ -0,0 +1,63 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "slices" +) + +// V4 checkpoints can have 16,384 predecessors with names up to 512 bytes. +// Bound only the V3 transcript, which embeds their exact dependency inventory; +// ordinary signed records retain the existing 16 MiB bound. +const maxFinalTranscriptV3Bytes = 64 << 20 + +func newFinalTranscriptV3(d CeremonyDefinition, candidate CandidateMetadata, review ReleaseReviewV4) (FinalTranscript, error) { + if d.Schema != DefinitionSchemaV4 { + return FinalTranscript{}, errors.New("final transcript v3 requires definition v4") + } + audits := make([]ArtifactRef, len(review.Audits)) + for i, pair := range review.Audits { + audits[i] = pair.Record + } + return NewFinalTranscript(FinalTranscript{ + Schema: FinalTranscriptSchemaV3, CeremonyID: d.CeremonyID, + AssurancePolicy: cloneAssurancePolicy(d.AssurancePolicy), + Definition: candidate.Definition, Circuit: d.Circuit, + Phase1: candidate.Phase1, Phase2: candidate.Phase2, + Audits: audits, OperationalEvidence: review.OperationalBundle, + ProvingKey: candidate.ProvingKey, VerifyingKey: candidate.VerifyingKey, + CardanoVerifyingKey: candidate.CardanoVerifyingKey, + FinalizedAt: review.ReleasedAt, ReleaseReview: &review, + }) +} + +func validateFinalTranscriptReviewV3(t FinalTranscript) error { + r := t.ReleaseReview + if r == nil { + return errors.New("final transcript v3 requires release_review") + } + if err := r.Validate(); err != nil { + return fmt.Errorf("final transcript release review: %w", err) + } + if t.CeremonyID != r.CeremonyID || t.FinalizedAt != r.ReleasedAt || t.OperationalEvidence != r.OperationalBundle { + return errors.New("final transcript differs from its exact review scope, bundle or time") + } + if !slices.Contains(r.RequiredArtifacts, t.Definition) { + return errors.New("final transcript definition is absent from reviewed dependencies") + } + for _, ref := range []ArtifactRef{t.ProvingKey, t.VerifyingKey, t.CardanoVerifyingKey} { + ref.Name = "final/candidate/" + ref.Name + if !slices.Contains(r.CandidateArtifacts, ref) { + return errors.New("final transcript key is absent from reviewed candidate") + } + } + if len(t.Audits) != len(r.Audits) { + return errors.New("final transcript audit count differs from review") + } + for i, pair := range r.Audits { + if t.Audits[i] != pair.Record { + return errors.New("final transcript audits differ from review") + } + } + return nil +} diff --git a/internal/mpcceremony/final_transcript_v3_test.go b/internal/mpcceremony/final_transcript_v3_test.go new file mode 100644 index 00000000..0d29d98b --- /dev/null +++ b/internal/mpcceremony/final_transcript_v3_test.go @@ -0,0 +1,142 @@ +package mpcceremony + +import ( + "bytes" + "fmt" + "os" + "path/filepath" + "slices" + "strings" + "testing" +) + +func transcriptFixtureV3(t *testing.T) (CeremonyDefinition, CandidateMetadata, ReleaseReviewV4) { + t.Helper() + d := trustedCoordinatorDefinition(t) + d.Auditors = nil + d.AssurancePolicy = &AssurancePolicy{} + var err error + d, err = FinalizeCeremonyDefinition(d) + if err != nil { + t.Fatal(err) + } + c := adversarialCandidate(t, d) + ref := func(name string) ArtifactRef { return ArtifactRef{Name: name, Digest: NewDigest([]byte(name))} } + pair := func(name string) SignedArtifactRefs { + return SignedArtifactRefs{Record: ref(name + ".json"), Signature: ref(name + ".sig")} + } + r := ReleaseReviewV4{CeremonyID: d.CeremonyID, ReviewCheckpoint: pair("checkpoints/review"), FinalCandidateCheckpoint: pair("checkpoints/candidate"), OperationalBundle: pair("operational/evidence-bundle"), Audits: []SignedArtifactRefs{}, ReplayVerification: CheckpointReplayVerificationV4{Method: CoordinatorReplayReleaseV1, ToolBinary: d.Software.ToolBinary}, ReleasedAt: "2026-07-23T16:00:00Z"} + for _, f := range []ArtifactRef{c.ProvingKey, c.VerifyingKey, c.CardanoVerifyingKey} { + f.Name = "final/candidate/" + f.Name + r.CandidateArtifacts = append(r.CandidateArtifacts, f) + } + slices.SortFunc(r.CandidateArtifacts, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + r.RequiredArtifacts, err = uniqueReleaseReviewArtifactsV4(append(slices.Clone(r.CandidateArtifacts), c.Definition)) + if err != nil { + t.Fatal(err) + } + return d, c, r +} + +func TestFinalTranscriptV3VersionAndBindings(t *testing.T) { + d, c, r := transcriptFixtureV3(t) + tx, err := newFinalTranscriptV3(d, c, r) + if err != nil { + t.Fatal(err) + } + raw, err := MarshalCanonical(tx) + if err != nil { + t.Fatal(err) + } + var decoded FinalTranscript + if err := UnmarshalCanonical(raw, &decoded); err != nil { + t.Fatal(err) + } + for _, schema := range []string{FinalTranscriptSchemaV1, FinalTranscriptSchema} { + changed := tx + changed.Schema = schema + if _, err := NewFinalTranscript(changed); err == nil { + t.Fatal("old schema accepted V4 review") + } + } + for name, change := range map[string]func(*FinalTranscript){ + "missing review": func(v *FinalTranscript) { v.ReleaseReview = nil }, + "time": func(v *FinalTranscript) { v.FinalizedAt = "2026-07-23T16:01:00Z" }, + "key": func(v *FinalTranscript) { v.ProvingKey.Digest = NewDigest([]byte("wrong")) }, + "definition": func(v *FinalTranscript) { v.Definition.Digest = NewDigest([]byte("wrong")) }, + "audit": func(v *FinalTranscript) { v.Audits = []ArtifactRef{c.Definition} }, + } { + t.Run(name, func(t *testing.T) { + v := tx + change(&v) + if _, err := NewFinalTranscript(v); err == nil { + t.Fatal("inconsistent review accepted") + } + }) + } + legacy := tx + legacy.Schema = FinalTranscriptSchema + legacy.ReleaseReview = nil + legacy, err = NewFinalTranscript(legacy) + if err != nil { + t.Fatal(err) + } + lb, err := MarshalCanonical(legacy) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(lb, []byte("release_review")) { + t.Fatal("legacy bytes gained review field") + } + for _, suffix := range []string{`,"release_review":null}`, `,"release_review":{}}`} { + bad := append(bytes.Clone(lb[:len(lb)-1]), []byte(suffix)...) + if err := UnmarshalCanonical(bad, &decoded); err == nil { + t.Fatal("legacy canonical bytes accepted new field") + } + } +} + +func TestFinalTranscriptV3MaximumDependencySize(t *testing.T) { + d, c, r := transcriptFixtureV3(t) + for len(r.RequiredArtifacts) < maxReleaseReviewArtifactsV4 { + prefix := fmt.Sprintf("z/%08d/", len(r.RequiredArtifacts)) + name := prefix + strings.Repeat("a", 250) + "/" + name += strings.Repeat("b", 512-len(name)) + r.RequiredArtifacts = append(r.RequiredArtifacts, ArtifactRef{Name: name, Digest: NewDigest([]byte("maximum-name fixture"))}) + } + tx, err := newFinalTranscriptV3(d, c, r) + if err != nil { + t.Fatal(err) + } + raw, err := MarshalCanonical(tx) + if err != nil { + t.Fatal(err) + } + if len(raw) <= maxSignedRecordBytes || len(raw) > maxFinalTranscriptV3Bytes { + t.Fatalf("maximum inventory size %d outside dedicated bound", len(raw)) + } + p := filepath.Join(t.TempDir(), FinalTranscriptFile) + if err := os.WriteFile(p, raw, 0600); err != nil { + t.Fatal(err) + } + loaded, err := readRegularBounded(p, maxFinalTranscriptV3Bytes) + if err != nil { + t.Fatal(err) + } + var result FinalTranscript + if err := UnmarshalCanonical(loaded, &result); err != nil { + t.Fatal(err) + } + if result.TranscriptID != tx.TranscriptID { + t.Fatal("large transcript changed") + } + if _, err := readRegularBounded(p, maxSignedRecordBytes); err == nil { + t.Fatal("ordinary JSON bound was widened") + } + if err := os.Truncate(p, maxFinalTranscriptV3Bytes+1); err != nil { + t.Fatal(err) + } + if _, err := readRegularBounded(p, maxFinalTranscriptV3Bytes); err == nil { + t.Fatal("oversized V3 transcript accepted") + } +} diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index 97561b38..1bc03b2b 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -21,7 +21,9 @@ import ( const ( DefinitionSchemaV1 = "proof-tool-mpc-ceremony-definition-v1" DefinitionSchemaV2 = "proof-tool-mpc-ceremony-definition-v2" - DefinitionSchema = "proof-tool-mpc-ceremony-definition-v3" + DefinitionSchemaV3 = "proof-tool-mpc-ceremony-definition-v3" + DefinitionSchemaV4 = "proof-tool-mpc-ceremony-definition-v4" + DefinitionSchema = DefinitionSchemaV3 DetachedSignatureSchema = "proof-tool-mpc-detached-signature-v1" ContributionAttestationSchema = "proof-tool-mpc-contribution-attestation-v2" ErasureAttestationSchema = "proof-tool-mpc-erasure-attestation-v2" @@ -33,6 +35,7 @@ const ( AuditRecordSchema = "proof-tool-mpc-audit-record-v1" FinalTranscriptSchemaV1 = "proof-tool-mpc-final-transcript-v1" FinalTranscriptSchema = "proof-tool-mpc-final-transcript-v2" + FinalTranscriptSchemaV3 = "proof-tool-mpc-final-transcript-v3" KeyVersionDestinationV2 = "ownership-destination-v2" CircuitIDDestinationV2 = "root-ownership-destination-v2/bls12-381/groth16" diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index 25e320dd..0fbb9f5c 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -866,7 +866,7 @@ func ValidatePublicWitnessReceipt( closeBytes []byte, receipt PublicWitnessReceipt, ) error { - if definition.Schema == DefinitionSchema && definition.AssurancePolicy != nil && definition.AssurancePolicy.PublicWitnessesPerPhase == 0 { + if definition.UsesSignedAssurancePolicy() && definition.AssurancePolicy != nil && definition.AssurancePolicy.PublicWitnessesPerPhase == 0 { return errors.New("public witnessing is disabled by the signed assurance policy") } if err := validatePublicWitnessCloseBinding(definition, close); err != nil { @@ -1043,7 +1043,7 @@ func verifyEnrollmentBinding(definition CeremonyDefinition, definitionBytes []by identity, role, index, ok := definitionRoleAt(definition, record.Identity.ID) switch record.Role { case EnrollmentPublicWitness, EnrollmentMirrorOperator: - if definition.Schema == DefinitionSchema && definition.AssurancePolicy != nil { + if definition.UsesSignedAssurancePolicy() && definition.AssurancePolicy != nil { if record.Role == EnrollmentPublicWitness && definition.AssurancePolicy.PublicWitnessesPerPhase == 0 { return errors.New("public-witness enrollment is disabled by the signed assurance policy") } diff --git a/internal/mpcceremony/operational_builder.go b/internal/mpcceremony/operational_builder.go index a99fb047..323c98bc 100644 --- a/internal/mpcceremony/operational_builder.go +++ b/internal/mpcceremony/operational_builder.go @@ -218,7 +218,7 @@ func PrepareImmutableMirrorReceipt( if err := definition.Validate(); err != nil { return ImmutableMirrorReceipt{}, nil, err } - if definition.Schema == DefinitionSchema && definition.AssurancePolicy.MirrorsPerAcceptedHead == 0 { + if definition.UsesSignedAssurancePolicy() && definition.AssurancePolicy.MirrorsPerAcceptedHead == 0 { return ImmutableMirrorReceipt{}, nil, errors.New("mirrors are disabled by the signed assurance policy") } if err := chain.ValidateAgainstDefinition(definition); err != nil { diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index 76025967..f578839b 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -10,8 +10,10 @@ import ( ) const ( + maxEnrollmentDisclosureBytes = 1 << 20 OperationalEvidenceBundleSchemaV2 = "proof-tool-mpc-operational-evidence-bundle-v2" OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v3" + OperationalEvidenceBundleSchemaV4 = "proof-tool-mpc-operational-evidence-bundle-v4" ) type SignedArtifactRefs struct { @@ -36,15 +38,15 @@ type AcceptedHeadOperationalEvidence struct { Index uint8 `json:"index"` PredecessorHeadID string `json:"predecessor_head_id"` AcceptedHeadID string `json:"accepted_head_id"` - OutboundHandoff SignedArtifactRefs `json:"outbound_handoff"` - OutboundReceipt SignedArtifactRefs `json:"outbound_receipt"` - ReturnHandoff SignedArtifactRefs `json:"return_handoff"` - ReturnReceipt SignedArtifactRefs `json:"return_receipt"` + OutboundHandoff SignedArtifactRefs `json:"outbound_handoff,omitempty"` + OutboundReceipt SignedArtifactRefs `json:"outbound_receipt,omitempty"` + ReturnHandoff SignedArtifactRefs `json:"return_handoff,omitempty"` + ReturnReceipt SignedArtifactRefs `json:"return_receipt,omitempty"` AcceptedChainPrefix SignedArtifactRefs `json:"accepted_chain_prefix"` MirrorReceipts []SignedArtifactRefs `json:"mirror_receipts"` } -func (e AcceptedHeadOperationalEvidence) Validate() error { +func (e AcceptedHeadOperationalEvidence) validate(custodyRequired bool) error { if e.Index == 0 || e.Index > MaxParticipants { return fmt.Errorf("accepted head index must be between 1 and %d", MaxParticipants) } @@ -57,17 +59,21 @@ func (e AcceptedHeadOperationalEvidence) Validate() error { if e.PredecessorHeadID == e.AcceptedHeadID { return errors.New("accepted head must differ from predecessor head") } - if err := e.OutboundHandoff.Validate(); err != nil { - return fmt.Errorf("outbound_handoff: %w", err) - } - if err := e.OutboundReceipt.Validate(); err != nil { - return fmt.Errorf("outbound_receipt: %w", err) - } - if err := e.ReturnHandoff.Validate(); err != nil { - return fmt.Errorf("return_handoff: %w", err) - } - if err := e.ReturnReceipt.Validate(); err != nil { - return fmt.Errorf("return_receipt: %w", err) + if custodyRequired { + if err := e.OutboundHandoff.Validate(); err != nil { + return fmt.Errorf("outbound_handoff: %w", err) + } + if err := e.OutboundReceipt.Validate(); err != nil { + return fmt.Errorf("outbound_receipt: %w", err) + } + if err := e.ReturnHandoff.Validate(); err != nil { + return fmt.Errorf("return_handoff: %w", err) + } + if err := e.ReturnReceipt.Validate(); err != nil { + return fmt.Errorf("return_receipt: %w", err) + } + } else if e.OutboundHandoff != (SignedArtifactRefs{}) || e.OutboundReceipt != (SignedArtifactRefs{}) || e.ReturnHandoff != (SignedArtifactRefs{}) || e.ReturnReceipt != (SignedArtifactRefs{}) { + return errors.New("operational evidence v4 forbids custody records") } if err := e.AcceptedChainPrefix.Validate(); err != nil { return fmt.Errorf("accepted_chain_prefix: %w", err) @@ -78,6 +84,8 @@ func (e AcceptedHeadOperationalEvidence) Validate() error { return validateSignedArtifactSet("mirror_receipts", e.MirrorReceipts) } +func (e AcceptedHeadOperationalEvidence) Validate() error { return e.validate(true) } + type PhaseOperationalEvidence struct { Phase Phase `json:"phase"` AcceptedChain SignedArtifactRefs `json:"accepted_chain"` @@ -85,11 +93,12 @@ type PhaseOperationalEvidence struct { AcceptedHeads []AcceptedHeadOperationalEvidence `json:"accepted_heads"` PublicWitnessQuorum uint8 `json:"public_witness_quorum"` PublicWitnessReceipts []SignedArtifactRefs `json:"public_witness_receipts"` - MultiRelayBeaconEvidence SignedArtifactRefs `json:"multi_relay_beacon_evidence"` + Beacon SignedArtifactRefs `json:"beacon,omitempty"` + MultiRelayBeaconEvidence SignedArtifactRefs `json:"multi_relay_beacon_evidence,omitempty"` RawBeaconResponses []ArtifactRef `json:"raw_beacon_responses"` } -func (p PhaseOperationalEvidence) Validate() error { +func (p PhaseOperationalEvidence) validate(custodyRequired, singleBeacon bool) error { if err := p.Phase.Validate(); err != nil { return err } @@ -103,7 +112,7 @@ func (p PhaseOperationalEvidence) Validate() error { return fmt.Errorf("accepted_heads must contain between 1 and %d entries", MaxParticipants) } for index, head := range p.AcceptedHeads { - if err := head.Validate(); err != nil { + if err := head.validate(custodyRequired); err != nil { return fmt.Errorf("accepted head %d: %w", index, err) } if head.Index != uint8(index+1) { @@ -120,8 +129,23 @@ func (p PhaseOperationalEvidence) Validate() error { if err := validateSignedArtifactSet("public_witness_receipts", p.PublicWitnessReceipts); err != nil { return err } - if err := p.MultiRelayBeaconEvidence.Validate(); err != nil { - return fmt.Errorf("multi_relay_beacon_evidence: %w", err) + if singleBeacon { + if err := p.Beacon.Validate(); err != nil { + return fmt.Errorf("beacon: %w", err) + } + if p.MultiRelayBeaconEvidence != (SignedArtifactRefs{}) { + return errors.New("operational evidence v4 forbids separate multi-relay beacon evidence") + } + if len(p.RawBeaconResponses) != 1 { + return errors.New("operational evidence v4 requires exactly one raw beacon response") + } + } else { + if p.Beacon != (SignedArtifactRefs{}) { + return errors.New("legacy operational evidence forbids the v4 beacon field") + } + if err := p.MultiRelayBeaconEvidence.Validate(); err != nil { + return fmt.Errorf("multi_relay_beacon_evidence: %w", err) + } } if err := validateArtifactSet("raw_beacon_responses", p.RawBeaconResponses); err != nil { return err @@ -129,10 +153,14 @@ func (p PhaseOperationalEvidence) Validate() error { return nil } -// OperationalEvidenceBundle is the one canonical release input for -// independently witnessed pre-beacon publication and multi-relay beacon -// retrieval in both phases. Every referenced byte string is content-addressed -// and resolved below one caller-supplied evidence root. +func (p PhaseOperationalEvidence) Validate() error { return p.validate(true, false) } + +// OperationalEvidenceBundle is the one canonical release input for operational +// evidence in both phases. Released formats retain independently witnessed +// pre-beacon publication and multi-relay retrieval. Definition V4 instead binds +// each signed beacon and its one verified raw response, verifies historical +// payload references through signed records rather than payload bytes, and +// separately requires the coordinator's full-replay claim in final review. type OperationalEvidenceBundle struct { Schema string `json:"schema"` CeremonyID string `json:"ceremony_id"` @@ -148,14 +176,14 @@ type OperationalEvidenceBundle struct { func (b OperationalEvidenceBundle) Validate() error { switch b.Schema { - case OperationalEvidenceBundleSchema: + case OperationalEvidenceBundleSchema, OperationalEvidenceBundleSchemaV4: if b.AssurancePolicy == nil { - return errors.New("operational evidence v3 requires assurance_policy") + return errors.New("operational evidence v3/v4 requires assurance_policy") } if b.Enrollments == nil || b.GovernanceRecords == nil || b.Phase1.AcceptedHeads == nil || b.Phase1.PublicWitnessReceipts == nil || b.Phase1.RawBeaconResponses == nil || b.Phase2.AcceptedHeads == nil || b.Phase2.PublicWitnessReceipts == nil || b.Phase2.RawBeaconResponses == nil { - return errors.New("operational evidence v3 requires explicit arrays; use [] for enabled collections with no records") + return errors.New("operational evidence v3/v4 requires explicit arrays; use [] for enabled collections with no records") } for _, phase := range []PhaseOperationalEvidence{b.Phase1, b.Phase2} { for _, head := range phase.AcceptedHeads { @@ -189,19 +217,21 @@ func (b OperationalEvidenceBundle) Validate() error { return err } } - if err := b.Phase1.Validate(); err != nil { + custodyRequired := b.Schema != OperationalEvidenceBundleSchemaV4 + singleBeacon := b.Schema == OperationalEvidenceBundleSchemaV4 + if err := b.Phase1.validate(custodyRequired, singleBeacon); err != nil { return fmt.Errorf("phase1: %w", err) } if b.Phase1.Phase != Phase1 { return errors.New("phase1 evidence has wrong phase") } - if err := b.Phase2.Validate(); err != nil { + if err := b.Phase2.validate(custodyRequired, singleBeacon); err != nil { return fmt.Errorf("phase2: %w", err) } if b.Phase2.Phase != Phase2 { return errors.New("phase2 evidence has wrong phase") } - if b.Schema == OperationalEvidenceBundleSchema { + if b.Schema == OperationalEvidenceBundleSchema || b.Schema == OperationalEvidenceBundleSchemaV4 { for _, phase := range []PhaseOperationalEvidence{b.Phase1, b.Phase2} { if phase.PublicWitnessQuorum != b.AssurancePolicy.PublicWitnessesPerPhase { return fmt.Errorf("%s public witness quorum does not match assurance_policy", phase.Phase) @@ -281,6 +311,8 @@ type VerifiedOperationalEvidence struct { // authenticated close records, witness signatures/quorum/timing, every raw // relay response, the pinned drand verification policy, and contribution-bound // signed cleanup claims. These claims do not establish physical erasure. +// V4 checks historical payload bindings, not presence or hashes of their bytes. +// V1–V3 continue to require and hash every genesis/contribution payload. func VerifyOperationalEvidenceBundle(options VerifyOperationalEvidenceOptions) (VerifiedOperationalEvidence, error) { if err := options.Definition.Validate(); err != nil { return VerifiedOperationalEvidence{}, err @@ -328,9 +360,13 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, return VerifiedOperationalEvidence{}, errors.New("operational evidence bundle does not bind ceremony coordinator") } expectedAssurance := defaultAssurancePolicy(options.Definition.Mode) - if options.Definition.Schema == DefinitionSchema { - if bundle.Schema != OperationalEvidenceBundleSchema { - return VerifiedOperationalEvidence{}, errors.New("definition v3 requires operational evidence bundle v3") + if options.Definition.UsesSignedAssurancePolicy() { + expectedSchema := OperationalEvidenceBundleSchema + if options.Definition.Schema == DefinitionSchemaV4 { + expectedSchema = OperationalEvidenceBundleSchemaV4 + } + if bundle.Schema != expectedSchema { + return VerifiedOperationalEvidence{}, fmt.Errorf("definition %s requires operational evidence schema %s", options.Definition.Schema, expectedSchema) } expectedAssurance = *options.Definition.AssurancePolicy if bundle.AssurancePolicy == nil || *bundle.AssurancePolicy != expectedAssurance { @@ -373,7 +409,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase1, options.Phase1Close, - enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchema, + enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), bundle.Schema == OperationalEvidenceBundleSchemaV4, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase1 operational evidence: %w", err) @@ -384,7 +420,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase2, options.Phase2Close, - enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchema, + enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), bundle.Schema == OperationalEvidenceBundleSchemaV4, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err) @@ -486,27 +522,29 @@ func latestOperationalTimestamp(root string, bundle OperationalEvidenceBundle) ( } advance(close.ClosedAt) for _, head := range phase.AcceptedHeads { - for _, pair := range []SignedArtifactRefs{head.OutboundHandoff, head.ReturnHandoff} { - raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) - if err != nil { - return time.Time{}, err + if bundle.Schema != OperationalEvidenceBundleSchemaV4 { + for _, pair := range []SignedArtifactRefs{head.OutboundHandoff, head.ReturnHandoff} { + raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) + if err != nil { + return time.Time{}, err + } + var record TransferHandoff + if err := UnmarshalCanonical(raw, &record); err != nil { + return time.Time{}, err + } + advance(record.CreatedAt) } - var record TransferHandoff - if err := UnmarshalCanonical(raw, &record); err != nil { - return time.Time{}, err + for _, pair := range []SignedArtifactRefs{head.OutboundReceipt, head.ReturnReceipt} { + raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) + if err != nil { + return time.Time{}, err + } + var record TransferReceipt + if err := UnmarshalCanonical(raw, &record); err != nil { + return time.Time{}, err + } + advance(record.ReceivedAt) } - advance(record.CreatedAt) - } - for _, pair := range []SignedArtifactRefs{head.OutboundReceipt, head.ReturnReceipt} { - raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) - if err != nil { - return time.Time{}, err - } - var record TransferReceipt - if err := UnmarshalCanonical(raw, &record); err != nil { - return time.Time{}, err - } - advance(record.ReceivedAt) } for _, pair := range head.MirrorReceipts { raw, err := verifyArtifactBytes(root, pair.Record, maxSignedRecordBytes) @@ -531,17 +569,29 @@ func latestOperationalTimestamp(root string, bundle OperationalEvidenceBundle) ( } advance(record.ObservedAt) } - beaconBytes, err := verifyArtifactBytes(root, phase.MultiRelayBeaconEvidence.Record, maxSignedRecordBytes) - if err != nil { - return time.Time{}, err + beaconPair := phase.MultiRelayBeaconEvidence + if bundle.Schema == OperationalEvidenceBundleSchemaV4 { + beaconPair = phase.Beacon } - var beacon MultiRelayBeaconEvidence - if err := UnmarshalCanonical(beaconBytes, &beacon); err != nil { + beaconBytes, err := verifyArtifactBytes(root, beaconPair.Record, maxSignedRecordBytes) + if err != nil { return time.Time{}, err } - advance(beacon.RecordedAt) - for _, observation := range beacon.Observations { - advance(observation.RetrievedAt) + if bundle.Schema == OperationalEvidenceBundleSchemaV4 { + var beacon BeaconRecord + if err := UnmarshalCanonical(beaconBytes, &beacon); err != nil { + return time.Time{}, err + } + advance(beacon.PublishedAt) + } else { + var beacon MultiRelayBeaconEvidence + if err := UnmarshalCanonical(beaconBytes, &beacon); err != nil { + return time.Time{}, err + } + advance(beacon.RecordedAt) + for _, observation := range beacon.Observations { + advance(observation.RetrievedAt) + } } } if latest.IsZero() { @@ -559,6 +609,7 @@ func verifyPhaseOperationalEvidence( enrollments map[string]EnrollmentRecord, assurance AssurancePolicy, legacy bool, + singleBeacon bool, ) ([]ArtifactRef, error) { if err := authenticated.Record.Validate(); err != nil { return nil, err @@ -608,15 +659,20 @@ func verifyPhaseOperationalEvidence( return nil, fmt.Errorf("accepted chain/close coherence: %w", err) } payloadRefs := make([]ArtifactRef, 0, len(chain.Records)+1) - if err := verifyLargeOperationalArtifact(root, chain.Genesis); err != nil { - return nil, fmt.Errorf("accepted chain genesis: %w", err) - } - payloadRefs = append(payloadRefs, chain.Genesis) - for index, record := range chain.Records { - if err := verifyLargeOperationalArtifact(root, record.OutputPayload); err != nil { - return nil, fmt.Errorf("accepted head %d output payload: %w", index+1, err) + // Only the signed V4 trust model delegates full contribution replay to the + // coordinator. All signed metadata and custody checks below still apply. + // This is not a caller-selectable option and does not relax legacy formats. + if definition.Schema != DefinitionSchemaV4 { + if err := verifyLargeOperationalArtifact(root, chain.Genesis); err != nil { + return nil, fmt.Errorf("accepted chain genesis: %w", err) + } + payloadRefs = append(payloadRefs, chain.Genesis) + for index, record := range chain.Records { + if err := verifyLargeOperationalArtifact(root, record.OutputPayload); err != nil { + return nil, fmt.Errorf("accepted head %d output payload: %w", index+1, err) + } + payloadRefs = append(payloadRefs, record.OutputPayload) } - payloadRefs = append(payloadRefs, record.OutputPayload) } acceptedHeadIDs := make([]string, len(chain.Records)) for index, record := range chain.Records { @@ -705,6 +761,40 @@ func verifyPhaseOperationalEvidence( return nil, err } + if singleBeacon { + beaconBytes, err := verifyArtifactBytes(root, phaseEvidence.Beacon.Record, maxSignedRecordBytes) + if err != nil { + return nil, err + } + beaconSignatureBytes, err := verifyArtifactBytes(root, phaseEvidence.Beacon.Signature, maxSignedRecordBytes) + if err != nil { + return nil, err + } + var beacon BeaconRecord + if err := VerifySignedRecord(beaconBytes, beaconSignatureBytes, &beacon, definition.Coordinator.KeyID, coordinatorPublicKey); err != nil { + return nil, fmt.Errorf("beacon signature: %w", err) + } + if err := ValidateBeacon(definition, authenticated.Record, beacon); err != nil { + return nil, err + } + if len(phaseEvidence.RawBeaconResponses) != 1 || phaseEvidence.RawBeaconResponses[0] != beacon.RawResponse { + return nil, errors.New("raw beacon response does not exactly match the signed beacon record") + } + raw, err := verifyArtifactBytes(root, beacon.RawResponse, maxDrandResponseBytes) + if err != nil { + return nil, err + } + randomness, err := VerifyDrandBeaconResponse(definition.BeaconPolicy, beacon.Round, raw) + if err != nil { + return nil, err + } + if randomness != beacon.RandomnessHex { + return nil, errors.New("signed beacon randomness differs from verified archived response") + } + refs = append(refs, phaseEvidence.Beacon.Record, phaseEvidence.Beacon.Signature, beacon.RawResponse) + return refs, nil + } + beaconBytes, err := verifyArtifactBytes( root, phaseEvidence.MultiRelayBeaconEvidence.Record, @@ -811,7 +901,7 @@ func verifyEnrollmentEvidence( 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 { + if _, err := verifyArtifactBytes(root, record.IndependenceDisclosure, maxEnrollmentDisclosureBytes); err != nil { return nil, nil, fmt.Errorf("enrollment %d independence disclosure: %w", index, err) } if _, duplicate := enrollments[signer.ID]; duplicate { @@ -895,6 +985,7 @@ func verifyAcceptedHeadEvidence( return nil, err } refs := make([]ArtifactRef, 0, len(heads)*10) + custodyRequired := definition.Schema != DefinitionSchemaV4 for index, evidence := range heads { record := chain.Records[index] if evidence.AcceptedHeadID != record.RecordID || @@ -1003,125 +1094,127 @@ func verifyAcceptedHeadEvidence( record.ErasureSignature, record.Verification, ) + accepted, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt) - outboundAny, pairRefs, err := verifyOperationalPair( - definition, - definitionBytes, - root, - evidence.OutboundHandoff, - RecordHandoff, - ) - if err != nil { - return nil, fmt.Errorf("accepted head %d outbound handoff: %w", index+1, err) - } - outbound := outboundAny.(*TransferHandoff) - if outbound.Phase != phase || outbound.Index != uint8(index+1) || - outbound.PredecessorHeadID != record.PreviousRecordID || - outbound.SenderID != definition.Coordinator.ID || - outbound.SenderKeyID != definition.Coordinator.KeyID || - outbound.RecipientID != participant.Identity.ID || - outbound.RecipientKeyID != participant.Identity.KeyID || - !slices.Equal(outbound.Files, []ArtifactRef{record.PreviousPayload}) { - return nil, fmt.Errorf("accepted head %d outbound handoff does not bind coordinator, participant, predecessor, and input", index+1) - } - refs = append(refs, pairRefs...) + if custodyRequired { + outboundAny, pairRefs, err := verifyOperationalPair( + definition, + definitionBytes, + root, + evidence.OutboundHandoff, + RecordHandoff, + ) + if err != nil { + return nil, fmt.Errorf("accepted head %d outbound handoff: %w", index+1, err) + } + outbound := outboundAny.(*TransferHandoff) + if outbound.Phase != phase || outbound.Index != uint8(index+1) || + outbound.PredecessorHeadID != record.PreviousRecordID || + outbound.SenderID != definition.Coordinator.ID || + outbound.SenderKeyID != definition.Coordinator.KeyID || + outbound.RecipientID != participant.Identity.ID || + outbound.RecipientKeyID != participant.Identity.KeyID || + !slices.Equal(outbound.Files, []ArtifactRef{record.PreviousPayload}) { + return nil, fmt.Errorf("accepted head %d outbound handoff does not bind coordinator, participant, predecessor, and input", index+1) + } + refs = append(refs, pairRefs...) - outboundReceiptAny, outboundReceiptRefs, err := verifyOperationalPair( - definition, - definitionBytes, - root, - evidence.OutboundReceipt, - RecordReceipt, - ) - if err != nil { - return nil, fmt.Errorf("accepted head %d outbound receipt: %w", index+1, err) - } - outboundReceipt := outboundReceiptAny.(*TransferReceipt) - if outboundReceipt.Kind != ReceiptReceiver { - return nil, fmt.Errorf("accepted head %d outbound receipt has wrong kind", index+1) - } - outboundBytes, err := verifyArtifactBytes(root, evidence.OutboundHandoff.Record, maxSignedRecordBytes) - if err != nil { - return nil, err - } - if err := VerifyTransferReceipt(outboundBytes, *outbound, *outboundReceipt); err != nil { - return nil, err - } - refs = append(refs, outboundReceiptRefs...) + outboundReceiptAny, outboundReceiptRefs, err := verifyOperationalPair( + definition, + definitionBytes, + root, + evidence.OutboundReceipt, + RecordReceipt, + ) + if err != nil { + return nil, fmt.Errorf("accepted head %d outbound receipt: %w", index+1, err) + } + outboundReceipt := outboundReceiptAny.(*TransferReceipt) + if outboundReceipt.Kind != ReceiptReceiver { + return nil, fmt.Errorf("accepted head %d outbound receipt has wrong kind", index+1) + } + outboundBytes, err := verifyArtifactBytes(root, evidence.OutboundHandoff.Record, maxSignedRecordBytes) + if err != nil { + return nil, err + } + if err := VerifyTransferReceipt(outboundBytes, *outbound, *outboundReceipt); err != nil { + return nil, err + } + refs = append(refs, outboundReceiptRefs...) - returnHandoffAny, returnHandoffRefs, err := verifyOperationalPair( - definition, - definitionBytes, - root, - evidence.ReturnHandoff, - RecordHandoff, - ) - if err != nil { - return nil, fmt.Errorf("accepted head %d return handoff: %w", index+1, err) - } - returnHandoff := returnHandoffAny.(*TransferHandoff) - expectedReturnFiles := []ArtifactRef{ - record.Attestation, - record.AttestationSignature, - record.Erasure, - record.ErasureSignature, - record.OutputPayload, - } - slices.SortFunc(expectedReturnFiles, compareArtifactRefName) - if returnHandoff.Phase != phase || returnHandoff.Index != uint8(index+1) || - returnHandoff.PredecessorHeadID != record.PreviousRecordID || - returnHandoff.SenderID != participant.Identity.ID || - returnHandoff.SenderKeyID != participant.Identity.KeyID || - returnHandoff.RecipientID != definition.Coordinator.ID || - returnHandoff.RecipientKeyID != definition.Coordinator.KeyID || - !slices.Equal(returnHandoff.Files, expectedReturnFiles) { - return nil, fmt.Errorf("accepted head %d return handoff does not bind participant, coordinator, predecessor head, and output evidence", index+1) - } - refs = append(refs, returnHandoffRefs...) + returnHandoffAny, returnHandoffRefs, err := verifyOperationalPair( + definition, + definitionBytes, + root, + evidence.ReturnHandoff, + RecordHandoff, + ) + if err != nil { + return nil, fmt.Errorf("accepted head %d return handoff: %w", index+1, err) + } + returnHandoff := returnHandoffAny.(*TransferHandoff) + expectedReturnFiles := []ArtifactRef{ + record.Attestation, + record.AttestationSignature, + record.Erasure, + record.ErasureSignature, + record.OutputPayload, + } + slices.SortFunc(expectedReturnFiles, compareArtifactRefName) + if returnHandoff.Phase != phase || returnHandoff.Index != uint8(index+1) || + returnHandoff.PredecessorHeadID != record.PreviousRecordID || + returnHandoff.SenderID != participant.Identity.ID || + returnHandoff.SenderKeyID != participant.Identity.KeyID || + returnHandoff.RecipientID != definition.Coordinator.ID || + returnHandoff.RecipientKeyID != definition.Coordinator.KeyID || + !slices.Equal(returnHandoff.Files, expectedReturnFiles) { + return nil, fmt.Errorf("accepted head %d return handoff does not bind participant, coordinator, predecessor head, and output evidence", index+1) + } + refs = append(refs, returnHandoffRefs...) - returnReceiptAny, returnReceiptRefs, err := verifyOperationalPair( - definition, - definitionBytes, - root, - evidence.ReturnReceipt, - RecordReceipt, - ) - if err != nil { - return nil, fmt.Errorf("accepted head %d return receipt: %w", index+1, err) - } - returnReceipt := returnReceiptAny.(*TransferReceipt) - if returnReceipt.Kind != ReceiptReceiver { - return nil, fmt.Errorf("accepted head %d return receipt has wrong kind", index+1) - } - returnHandoffBytes, err := verifyArtifactBytes(root, evidence.ReturnHandoff.Record, maxSignedRecordBytes) - if err != nil { - return nil, err - } - if err := VerifyTransferReceipt(returnHandoffBytes, *returnHandoff, *returnReceipt); err != nil { - return nil, err - } - refs = append(refs, returnReceiptRefs...) + returnReceiptAny, returnReceiptRefs, err := verifyOperationalPair( + definition, + definitionBytes, + root, + evidence.ReturnReceipt, + RecordReceipt, + ) + if err != nil { + return nil, fmt.Errorf("accepted head %d return receipt: %w", index+1, err) + } + returnReceipt := returnReceiptAny.(*TransferReceipt) + if returnReceipt.Kind != ReceiptReceiver { + return nil, fmt.Errorf("accepted head %d return receipt has wrong kind", index+1) + } + returnHandoffBytes, err := verifyArtifactBytes(root, evidence.ReturnHandoff.Record, maxSignedRecordBytes) + if err != nil { + return nil, err + } + if err := VerifyTransferReceipt(returnHandoffBytes, *returnHandoff, *returnReceipt); err != nil { + return nil, err + } + refs = append(refs, returnReceiptRefs...) - predecessorAcceptedAt := definition.CreatedAt - if index > 0 { - predecessorAcceptedAt = chain.Records[index-1].AcceptedAt - } - predecessorAccepted, _ := time.Parse(time.RFC3339Nano, predecessorAcceptedAt) - outboundCreated, _ := time.Parse(time.RFC3339Nano, outbound.CreatedAt) - outboundReceived, _ := time.Parse(time.RFC3339Nano, outboundReceipt.ReceivedAt) - contributed, _ := time.Parse(time.RFC3339Nano, attestation.ContributedAt) - destroyed, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt) - returnCreated, _ := time.Parse(time.RFC3339Nano, returnHandoff.CreatedAt) - returnReceived, _ := time.Parse(time.RFC3339Nano, returnReceipt.ReceivedAt) - accepted, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt) - if !outboundCreated.After(predecessorAccepted) || - !outboundReceived.After(outboundCreated) || - !contributed.After(outboundReceived) || - !returnCreated.After(contributed) || - !returnCreated.After(destroyed) || - !returnReceived.After(returnCreated) || - !accepted.After(returnReceived) { - return nil, fmt.Errorf("accepted head %d custody/contribution/erasure/acceptance timestamps are not strictly ordered", index+1) + predecessorAcceptedAt := definition.CreatedAt + if index > 0 { + predecessorAcceptedAt = chain.Records[index-1].AcceptedAt + } + predecessorAccepted, _ := time.Parse(time.RFC3339Nano, predecessorAcceptedAt) + outboundCreated, _ := time.Parse(time.RFC3339Nano, outbound.CreatedAt) + outboundReceived, _ := time.Parse(time.RFC3339Nano, outboundReceipt.ReceivedAt) + contributed, _ := time.Parse(time.RFC3339Nano, attestation.ContributedAt) + destroyed, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt) + returnCreated, _ := time.Parse(time.RFC3339Nano, returnHandoff.CreatedAt) + returnReceived, _ := time.Parse(time.RFC3339Nano, returnReceipt.ReceivedAt) + if !outboundCreated.After(predecessorAccepted) || + !outboundReceived.After(outboundCreated) || + !contributed.After(outboundReceived) || + !returnCreated.After(contributed) || + !returnCreated.After(destroyed) || + !returnReceived.After(returnCreated) || + !accepted.After(returnReceived) { + return nil, fmt.Errorf("accepted head %d custody/contribution/erasure/acceptance timestamps are not strictly ordered", index+1) + } } mirrorIDs := make(map[string]struct{}, len(evidence.MirrorReceipts)) diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 75552a5c..1578d2e6 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -256,6 +256,31 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Fatal("tampered accepted output payload unexpectedly accepted") } }) + for _, which := range []string{"genesis", "contribution"} { + t.Run("v3 missing "+which+" payload", func(t *testing.T) { + f := newOperationalBundleFixtureWithAssurance(t, &AssurancePolicy{}) + if f.definition.Schema != DefinitionSchemaV3 { + t.Fatalf("test must retain released v3 semantics, got %s", f.definition.Schema) + } + if err := verify(f); err != nil { + t.Fatal(err) + } + var chain Chain + if _, err := readCanonicalFile(filepath.Join(f.root, f.bundle.Phase1.AcceptedChain.Record.Name), &chain); err != nil { + t.Fatal(err) + } + ref := chain.Genesis + if which == "contribution" { + ref = chain.Records[0].OutputPayload + } + if err := os.Remove(filepath.Join(f.root, ref.Name)); err != nil { + t.Fatal(err) + } + if err := verify(f); err == nil { + t.Fatal("v3 accepted missing historical payload") + } + }) + } t.Run("actor overlap witness", func(t *testing.T) { f := newOperationalBundleFixture(t) pair := f.bundle.Phase1.PublicWitnessReceipts[0] @@ -436,6 +461,37 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { }) } +func TestOperationalEvidenceV4UsesOneSignedBeaconAndNoMultiRelayRecord(t *testing.T) { + f := newOperationalBundleFixture(t) + bundle := f.bundle + bundle.Schema = OperationalEvidenceBundleSchemaV4 + for _, phase := range []*PhaseOperationalEvidence{&bundle.Phase1, &bundle.Phase2} { + phase.Beacon = phase.MultiRelayBeaconEvidence + phase.MultiRelayBeaconEvidence = SignedArtifactRefs{} + phase.RawBeaconResponses = phase.RawBeaconResponses[:1] + for index := range phase.AcceptedHeads { + phase.AcceptedHeads[index].OutboundHandoff = SignedArtifactRefs{} + phase.AcceptedHeads[index].OutboundReceipt = SignedArtifactRefs{} + phase.AcceptedHeads[index].ReturnHandoff = SignedArtifactRefs{} + phase.AcceptedHeads[index].ReturnReceipt = SignedArtifactRefs{} + } + } + if err := bundle.Validate(); err != nil { + t.Fatalf("single-beacon V4 bundle rejected: %v", err) + } + + bad := bundle + bad.Phase1.MultiRelayBeaconEvidence = f.bundle.Phase1.MultiRelayBeaconEvidence + if err := bad.Validate(); err == nil { + t.Fatal("V4 accepted a separate multi-relay beacon record") + } + bad = bundle + bad.Phase1.RawBeaconResponses = append(bad.Phase1.RawBeaconResponses, f.bundle.Phase1.RawBeaconResponses[1]) + if err := bad.Validate(); err == nil { + t.Fatal("V4 accepted more than one raw beacon response") + } +} + func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { return newOperationalBundleFixtureConfigured(t, nil, false) } diff --git a/internal/mpcceremony/operational_prepare.go b/internal/mpcceremony/operational_prepare.go index 4eff08f8..daef154e 100644 --- a/internal/mpcceremony/operational_prepare.go +++ b/internal/mpcceremony/operational_prepare.go @@ -25,7 +25,7 @@ type discoveredOperational struct { func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt string) (OperationalPreparation, error) { bundleSchema := OperationalEvidenceBundleSchema bundleAssurance := cloneAssurancePolicy(definition.AssurancePolicy) - if definition.Schema != DefinitionSchema { + if !definition.UsesSignedAssurancePolicy() { bundleSchema = OperationalEvidenceBundleSchemaV2 bundleAssurance = nil } @@ -42,7 +42,7 @@ func PrepareOperationalEvidence(definition CeremonyDefinition, root, assembledAt return result, err } assurance := defaultAssurancePolicy(definition.Mode) - if definition.Schema == DefinitionSchema { + if definition.UsesSignedAssurancePolicy() { assurance = *definition.AssurancePolicy } var records []discoveredOperational diff --git a/internal/mpcceremony/release_checksums_v4_test.go b/internal/mpcceremony/release_checksums_v4_test.go new file mode 100644 index 00000000..f55f8273 --- /dev/null +++ b/internal/mpcceremony/release_checksums_v4_test.go @@ -0,0 +1,60 @@ +package mpcceremony + +import ( + "fmt" + "os" + "path/filepath" + "strings" + "testing" +) + +func TestV4ChecksumMaximumBound(t *testing.T) { + var data strings.Builder + names := make([]string, maxReleaseReviewArtifactsV4+5) + hash := strings.Repeat("a", 64) + for i := range names { + prefix := fmt.Sprintf("z/%08d/", i) + name := prefix + strings.Repeat("a", 250) + "/" + name += strings.Repeat("b", 512-len(name)) + names[i] = name + fmt.Fprintf(&data, "%s %s\n", hash, name) + } + raw := []byte(data.String()) + if len(raw) != maxReleaseChecksumsV4Bytes || len(raw) <= maxSignedRecordBytes { + t.Fatalf("checksum maximum size %d does not match dedicated bound %d", len(raw), maxReleaseChecksumsV4Bytes) + } + file := filepath.Join(t.TempDir(), ReleaseChecksumsFile) + if err := os.WriteFile(file, raw, 0600); err != nil { + t.Fatal(err) + } + loaded, err := readRegularBounded(file, maxReleaseChecksumsV4Bytes) + if err != nil { + t.Fatal(err) + } + entries, err := parseChecksumsExact(loaded, ReleaseChecksumsFile, names) + if err != nil || len(entries) != len(names) { + t.Fatalf("maximum checksum inventory: %d, %v", len(entries), err) + } + if _, err := readRegularBounded(file, maxSignedRecordBytes); err == nil { + t.Fatal("legacy checksum bound widened") + } + if err := os.Truncate(file, maxReleaseChecksumsV4Bytes+1); err != nil { + t.Fatal(err) + } + if _, err := readRegularBounded(file, maxReleaseChecksumsV4Bytes); err == nil { + t.Fatal("oversized checksum file accepted") + } +} + +func TestExactChecksumParserRejectsInventoryChanges(t *testing.T) { + hash := strings.Repeat("a", 64) + line := func(name string) string { return hash + " " + name + "\n" } + for _, raw := range []string{line("a"), line("a") + line("b") + line("c"), line("a") + line("a"), line("a") + line("c"), line("b") + line("a"), line("../a") + line("b")} { + if _, err := parseChecksumsExact([]byte(raw), ReleaseChecksumsFile, []string{"a", "b"}); err == nil { + t.Fatal("changed inventory accepted") + } + } + if _, err := parseChecksumsExact([]byte(line("a")+line("b")), ReleaseChecksumsFile, []string{"a", "b"}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/mpcceremony/release_compatibility_test.go b/internal/mpcceremony/release_compatibility_test.go new file mode 100644 index 00000000..e1e5e3f3 --- /dev/null +++ b/internal/mpcceremony/release_compatibility_test.go @@ -0,0 +1,30 @@ +package mpcceremony + +import ( + "strings" + "testing" +) + +func TestReleasedSignerReplayRequirementsRemainExplicit(t *testing.T) { + for _, schema := range []string{"proof-tool-mpc-ceremony-definition-v1", "proof-tool-mpc-ceremony-definition-v2"} { + if err := verifyRequiredReleaseSignerReplay(schema, SignReleaseOptions{}); err != nil { + t.Fatalf("legacy replay procedure changed for %s: %v", schema, err) + } + } + for _, options := range []SignReleaseOptions{{}, {Replay: &ReplayPaths{}}, {Circuit: &CompiledCircuit{}}} { + err := verifyRequiredReleaseSignerReplay("proof-tool-mpc-ceremony-definition-v3", options) + if err == nil || !strings.Contains(err.Error(), "requires independent two-phase replay") { + t.Fatalf("released v3 accepted missing replay inputs: %v", err) + } + } + // Merely filling the pointers is not replay evidence. + err := verifyRequiredReleaseSignerReplay("proof-tool-mpc-ceremony-definition-v3", SignReleaseOptions{Replay: &ReplayPaths{}, Circuit: &CompiledCircuit{}}) + if err == nil || !strings.Contains(err.Error(), "release-signer independent replay") { + t.Fatalf("released v3 skipped actual replay: %v", err) + } + for _, schema := range []string{"", "proof-tool-mpc-ceremony-definition-v4", "unknown"} { + if err := verifyRequiredReleaseSignerReplay(schema, SignReleaseOptions{}); err == nil { + t.Fatalf("unimplemented schema %q selected legacy behavior", schema) + } + } +} diff --git a/internal/mpcceremony/release_layout_v4.go b/internal/mpcceremony/release_layout_v4.go new file mode 100644 index 00000000..f9876ae2 --- /dev/null +++ b/internal/mpcceremony/release_layout_v4.go @@ -0,0 +1,125 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path/filepath" + "reflect" + "slices" + "strings" + + "proof-tool/internal/keybundle" +) + +// One SHA-256, two spaces, a maximum-length logical name and a newline per +// dependency/generated file. This V4 bound does not widen legacy checksums. +const maxReleaseChecksumsV4Bytes = (maxReleaseReviewArtifactsV4 + 5) * (64 + 2 + 512 + 1) + +func releasePhysicalNameV4(logical string) (string, error) { + if err := validateArtifactName(logical); err != nil { + return "", err + } + if err := validatePortableStorageName(logical); err != nil { + return "", err + } + const prefix = "final/candidate/" + if !strings.HasPrefix(logical, prefix) { + return logical, nil + } + name := strings.TrimPrefix(logical, prefix) + if !slices.Contains(append(candidateChecksumNames(), CandidateChecksumsFile), name) { + return "", errors.New("unsupported V4 candidate alias") + } + return name, nil +} + +func releaseGeneratedNamesV4() []string { + return []string{FinalTranscriptFile, keybundle.ManifestFile, keybundle.ManifestSignatureFile, keybundle.ManifestPublicKeyFile, ReleaseChecksumsFile} +} + +func validateReleaseDestinationV4(source, destination string) error { + if strings.TrimSpace(source) == "" || strings.TrimSpace(destination) == "" { + return errors.New("V4 release source and destination directories are required") + } + source, err := filepath.EvalSymlinks(source) + if err != nil { + return err + } + source, err = filepath.Abs(source) + if err != nil { + return err + } + destination, err = filepath.Abs(destination) + if err != nil { + return err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(destination)) + if err != nil { + return err + } + destination = filepath.Join(parent, filepath.Base(destination)) + within := func(a, b string) bool { + rel, err := filepath.Rel(a, b) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) + } + if within(source, destination) || within(destination, source) { + return errors.New("V4 release directory must be separate from the source tree, not inside or above it") + } + return nil +} + +// The exact union rejects collisions even when two logical names have the same +// digest. Generated release files are not part of their own dependency set. +func releaseDependencyNamesV4(refs []ArtifactRef) ([]string, error) { + seen := map[string]bool{} + for _, name := range releaseGeneratedNamesV4() { + seen[name] = true + } + names := make([]string, 0, len(refs)) + for _, ref := range refs { + name, err := releasePhysicalNameV4(ref.Name) + if err != nil { + return nil, err + } + if seen[name] { + return nil, fmt.Errorf("V4 release path collision at %q", name) + } + seen[name] = true + names = append(names, name) + } + // A filename must not also be the parent directory of another file. + for name := range seen { + for parent := filepath.ToSlash(filepath.Dir(name)); parent != "."; parent = filepath.ToSlash(filepath.Dir(parent)) { + if seen[parent] { + return nil, fmt.Errorf("V4 release file/directory collision at %q", parent) + } + } + } + slices.Sort(names) + return names, nil +} + +// Used only inside V4 release verification, which independently checks the +// complete package tree. Legacy closed-tree checks are not relaxed. +func verifyCandidateSubsetV4(d CeremonyDefinition, definition ArtifactRef, dir string, candidate CandidateMetadata, candidateRef ArtifactRef) (CandidateMetadata, []ArtifactRef, error) { + refs := make([]ArtifactRef, 0, len(candidateChecksumNames())+1) + for _, name := range append(candidateChecksumNames(), CandidateChecksumsFile) { + ref, err := artifactRefForFile(name, filepath.Join(dir, name)) + if err != nil { + return CandidateMetadata{}, nil, err + } + refs = append(refs, ref) + } + slices.SortFunc(refs, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + if !slices.Contains(refs, candidateRef) { + return CandidateMetadata{}, nil, errors.New("V4 candidate metadata changed during verification") + } + again, ref, err := verifyCandidate(d, definition, dir) + if err != nil { + return CandidateMetadata{}, nil, err + } + if ref != candidateRef || !reflect.DeepEqual(again, candidate) { + return CandidateMetadata{}, nil, errors.New("V4 candidate changed during verification") + } + return candidate, refs, nil +} diff --git a/internal/mpcceremony/release_layout_v4_test.go b/internal/mpcceremony/release_layout_v4_test.go new file mode 100644 index 00000000..fcee987c --- /dev/null +++ b/internal/mpcceremony/release_layout_v4_test.go @@ -0,0 +1,90 @@ +package mpcceremony + +import ( + "os" + "path/filepath" + "testing" +) + +func TestReleaseLayoutV4AliasesAndCollisions(t *testing.T) { + for _, name := range append(candidateChecksumNames(), CandidateChecksumsFile) { + got, err := releasePhysicalNameV4("final/candidate/" + name) + if err != nil || got != name { + t.Fatalf("candidate alias %s: %s %v", name, got, err) + } + } + for _, name := range []string{"final/candidate/unknown", "final/candidate/nested/ownership.pk", "../outside", "final/candidate/../ownership.pk"} { + if _, err := releasePhysicalNameV4(name); err == nil { + t.Fatalf("bad alias accepted: %s", name) + } + } + ref := func(name string) ArtifactRef { return ArtifactRef{Name: name, Digest: NewDigest([]byte("same"))} } + for _, refs := range [][]ArtifactRef{ + {ref("final/candidate/" + NativeProvingKeyFile), ref(NativeProvingKeyFile)}, + {ref(FinalTranscriptFile)}, + {ref("files"), ref("files/child")}, + {ref(FinalTranscriptFile + "/child")}, + } { + if _, err := releaseDependencyNamesV4(refs); err == nil { + t.Fatal("release collision accepted") + } + } +} + +func TestV4ReleaseTreeRejectsLinks(t *testing.T) { + dir := t.TempDir() + name := "file.json" + p := filepath.Join(dir, name) + if err := os.WriteFile(p, []byte("public bytes"), 0600); err != nil { + t.Fatal(err) + } + if err := verifyExactReleaseFiles(dir, []string{name}, true); err != nil { + t.Fatal(err) + } + link := filepath.Join(t.TempDir(), "outside-link") + if err := os.Link(p, link); err != nil { + t.Fatal(err) + } + if err := verifyExactReleaseFiles(dir, []string{name}, true); err == nil { + t.Fatal("external hardlink accepted") + } + if err := os.Remove(link); err != nil { + t.Fatal(err) + } + if err := os.Remove(p); err != nil { + t.Fatal(err) + } + if err := os.Symlink("missing", p); err != nil { + t.Fatal(err) + } + if err := verifyExactReleaseFiles(dir, []string{name}, true); err == nil { + t.Fatal("symlink accepted") + } +} + +func TestReleaseDestinationV4Disjoint(t *testing.T) { + base := t.TempDir() + source := filepath.Join(base, "source") + if err := os.Mkdir(source, 0700); err != nil { + t.Fatal(err) + } + nested := filepath.Join(source, "final", "candidate") + if err := os.MkdirAll(nested, 0700); err != nil { + t.Fatal(err) + } + for _, destination := range []string{source, filepath.Join(source, "release"), filepath.Join(nested, "release"), base, ""} { + if err := validateReleaseDestinationV4(source, destination); err == nil { + t.Fatalf("overlapping destination accepted: %s", destination) + } + } + if err := validateReleaseDestinationV4(source, filepath.Join(base, "release")); err != nil { + t.Fatal(err) + } + alias := filepath.Join(base, "source-alias") + if err := os.Symlink(source, alias); err != nil { + t.Fatal(err) + } + if err := validateReleaseDestinationV4(source, filepath.Join(alias, "release")); err == nil { + t.Fatal("symlink parent bypassed source separation") + } +} diff --git a/internal/mpcceremony/release_links_other.go b/internal/mpcceremony/release_links_other.go new file mode 100644 index 00000000..f5924f6d --- /dev/null +++ b/internal/mpcceremony/release_links_other.go @@ -0,0 +1,12 @@ +//go:build !linux && !darwin + +package mpcceremony + +import ( + "errors" + "os" +) + +func requireSingleLinkV4(_ os.FileInfo) error { + return errors.New("V4 release link validation requires Linux or macOS") +} diff --git a/internal/mpcceremony/release_links_unix.go b/internal/mpcceremony/release_links_unix.go new file mode 100644 index 00000000..8c2f43b5 --- /dev/null +++ b/internal/mpcceremony/release_links_unix.go @@ -0,0 +1,17 @@ +//go:build linux || darwin + +package mpcceremony + +import ( + "errors" + "os" + "syscall" +) + +func requireSingleLinkV4(info os.FileInfo) error { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok || stat.Nlink != 1 { + return errors.New("V4 release files must have exactly one hard link") + } + return nil +} diff --git a/internal/mpcceremony/release_v4.go b/internal/mpcceremony/release_v4.go new file mode 100644 index 00000000..8ece9660 --- /dev/null +++ b/internal/mpcceremony/release_v4.go @@ -0,0 +1,306 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + "time" + + "proof-tool/internal/artifact" + "proof-tool/internal/keybundle" +) + +type SignReleaseV4Options struct { + Trust TrustPaths + ArtifactRoot string + ReviewCheckpoint SignedArtifactRefs + OperationalBundle SignedArtifactRefs + ReleaseDir string + ReleaseSigningKey string + SignatureKeyID string + ReleasedAt time.Time +} + +type VerifyReleaseV4Options struct { + Trust TrustPaths + KeysDir string + TrustedPublicKeyHex string + ExpectedSignatureKeyID string +} + +// SignReleaseV4 signs a fresh local package after recomputing the exact review +// from its copied bytes. It does not append a checkpoint, authorize production +// use or publish to storage. The caller must keep it private until those gates. +func SignReleaseV4(o SignReleaseV4Options) (*SignReleaseResult, error) { + if err := validateReleaseDestinationV4(o.ArtifactRoot, o.ReleaseDir); err != nil { + return nil, err + } + review, err := VerifyReleaseReviewV4(o.Trust, o.ArtifactRoot, o.ReviewCheckpoint, o.OperationalBundle, o.ReleasedAt) + if err != nil { + return nil, err + } + trusted, err := loadOperationalCeremony(o.Trust) + if err != nil { + return nil, err + } + d := trusted.Definition + if err := validateReleaseSigningDefinitionV4(d, review); err != nil { + return nil, err + } + if o.SignatureKeyID != d.ReleaseSigner.KeyID { + return nil, errors.New("release signature key id differs from signed definition") + } + private, public, err := keybundle.LoadExistingPrivateKey(o.ReleaseSigningKey) + if err != nil { + return nil, err + } + if err := requireIdentityKey(d.ReleaseSigner, public); err != nil { + return nil, fmt.Errorf("release signing key: %w", err) + } + names, err := releaseDependencyNamesV4(review.RequiredArtifacts) + if err != nil { + return nil, err + } + staging, err := createRecoveryStagingDir(o.ReleaseDir) + if err != nil { + return nil, err + } + committed := false + defer func() { + if !committed { + _ = os.RemoveAll(staging) + } + }() + for _, ref := range review.RequiredArtifacts { + name, err := releasePhysicalNameV4(ref.Name) + if err != nil { + return nil, err + } + destination := filepath.Join(staging, filepath.FromSlash(name)) + if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return nil, err + } + if err := copyRegularNoReplace(filepath.Join(o.ArtifactRoot, filepath.FromSlash(ref.Name)), destination); err != nil { + return nil, err + } + } + if err := verifyExactReleaseFiles(staging, names, true); err != nil { + return nil, err + } + // Keep the independent coordinator-key anchor but authenticate the copied + // definition/signature. Their exact logical names are in the signed head. + head, err := readReleaseReviewHeadV4(o.Trust, o.ArtifactRoot, review.ReviewCheckpoint) + if err != nil { + return nil, err + } + stagedTrust := o.Trust + stagedTrust.DefinitionPath = filepath.Join(staging, head.Definition.Record.Name) + stagedTrust.DefinitionSignaturePath = filepath.Join(staging, head.Definition.Signature.Name) + again, err := verifyReleaseReviewV4(stagedTrust, staging, review.ReviewCheckpoint, review.OperationalBundle, o.ReleasedAt, true) + if err != nil { + return nil, fmt.Errorf("copied release review: %w", err) + } + if !reflect.DeepEqual(review, again) { + return nil, errors.New("copied release differs from approved review") + } + candidate, _, err := verifyCandidate(d, head.Definition.Record, staging) + if err != nil { + return nil, err + } + transcript, err := newFinalTranscriptV3(d, candidate, again) + if err != nil { + return nil, err + } + raw, err := MarshalCanonical(transcript) + if err != nil { + return nil, err + } + if len(raw) > maxFinalTranscriptV3Bytes { + return nil, errors.New("V3 final transcript exceeds its bounded size") + } + if err := writeBytesNoReplace(filepath.Join(staging, FinalTranscriptFile), raw, 0600); err != nil { + return nil, err + } + manifest := releaseManifestV4(d, candidate, raw, review.ReleasedAt) + mb, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return nil, err + } + mb = append(mb, '\n') + for _, file := range []struct { + name string + data []byte + }{ + {keybundle.ManifestFile, mb}, + {keybundle.ManifestSignatureFile, []byte(hex.EncodeToString(ed25519.Sign(private, mb)) + "\n")}, + {keybundle.ManifestPublicKeyFile, []byte(hex.EncodeToString(public) + "\n")}, + } { + if err := writeBytesNoReplace(filepath.Join(staging, file.name), file.data, 0600); err != nil { + return nil, err + } + } + checksumNames := append(slices.Clone(names), FinalTranscriptFile, keybundle.ManifestFile, keybundle.ManifestSignatureFile, keybundle.ManifestPublicKeyFile) + if err := writeChecksumsNoReplace(staging, filepath.Join(staging, ReleaseChecksumsFile), checksumNames); err != nil { + return nil, err + } + if _, err := VerifyReleaseV4(VerifyReleaseV4Options{Trust: stagedTrust, KeysDir: staging, TrustedPublicKeyHex: hex.EncodeToString(public), ExpectedSignatureKeyID: o.SignatureKeyID}); err != nil { + return nil, fmt.Errorf("V4 release self-verification: %w", err) + } + if err := syncDirectory(staging); err != nil { + return nil, err + } + if err := publishReleaseDirectory(staging, o.ReleaseDir); err != nil { + return nil, err + } + committed = true + // Exact recovery may reuse an already-existing destination. Check its V4 + // invariants too; byte equality alone does not detect external hard links. + finalTrust := o.Trust + finalTrust.DefinitionPath = filepath.Join(o.ReleaseDir, head.Definition.Record.Name) + finalTrust.DefinitionSignaturePath = filepath.Join(o.ReleaseDir, head.Definition.Signature.Name) + if _, err := VerifyReleaseV4(VerifyReleaseV4Options{Trust: finalTrust, KeysDir: o.ReleaseDir, TrustedPublicKeyHex: hex.EncodeToString(public), ExpectedSignatureKeyID: o.SignatureKeyID}); err != nil { + return nil, &publicationError{publicationCommitted, "verify V4 destination; retain for investigation", err} + } + return &SignReleaseResult{ManifestPath: filepath.Join(o.ReleaseDir, keybundle.ManifestFile), ManifestSignature: filepath.Join(o.ReleaseDir, keybundle.ManifestSignatureFile), ManifestPublicKey: filepath.Join(o.ReleaseDir, keybundle.ManifestPublicKeyFile), FinalTranscript: filepath.Join(o.ReleaseDir, FinalTranscriptFile), OperationalEvidence: filepath.Join(o.ReleaseDir, OperationalEvidenceBundleFile), ChecksumsPath: filepath.Join(o.ReleaseDir, ReleaseChecksumsFile)}, nil +} + +func validateReleaseSigningDefinitionV4(d CeremonyDefinition, review ReleaseReviewV4) error { + if d.CeremonyID != review.CeremonyID { + return errors.New("ceremony definition changed after release review") + } + if err := VerifyRunningSoftwareForMode(d.Software, d.Mode); err != nil { + return fmt.Errorf("release signing software: %w", err) + } + return nil +} + +func readReleaseReviewHeadV4(trust TrustPaths, root string, refs SignedArtifactRefs) (CheckpointV4, error) { + t, err := loadOperationalCeremony(trust) + if err != nil { + return CheckpointV4{}, err + } + db, err := MarshalCanonical(t.Definition) + if err != nil { + return CheckpointV4{}, err + } + ds, err := readRegularBounded(trust.DefinitionSignaturePath, 4096) + if err != nil { + return CheckpointV4{}, err + } + r, err := openCheckpointReaderV4(root) + if err != nil { + return CheckpointV4{}, err + } + defer func() { _ = r.root.Close() }() + rb, rs, err := r.pair(refs) + if err != nil { + return CheckpointV4{}, err + } + return VerifySignedCheckpointV4(t.Definition, db, ds, rb, rs) +} + +func releaseManifestV4(d CeremonyDefinition, c CandidateMetadata, transcript []byte, at string) artifact.KeyManifest { + return artifact.KeyManifest{ + Schema: artifact.ManifestSchema, KeyVersion: d.Circuit.KeyVersion, CircuitID: d.Circuit.CircuitID, Curve: d.Circuit.Curve, Backend: d.Circuit.Backend, + VKHash: c.VerifyingKey.Digest.Blake2b256, ProvingKeySHA256: c.ProvingKey.Digest.SHA256, ProvingKeyBlake2b256: c.ProvingKey.Digest.Blake2b256, ProvingKeySize: c.ProvingKey.Digest.Size, + VerifyingKeySHA256: c.VerifyingKey.Digest.SHA256, VerifyingKeySize: c.VerifyingKey.Digest.Size, ConstraintSystemHash: c.ConstraintSystem.Digest.Blake2b256, + CircuitSourceCommit: d.Software.SourceCommit, ProofToolVersion: d.Software.ProofToolVersion, GnarkVersion: d.Software.GnarkVersion, + SetupTranscriptHash: NewDigest(transcript).Blake2b256, PublishedAt: at, SignatureKeyID: d.ReleaseSigner.KeyID, + } +} + +// VerifyReleaseV4 authenticates the exact local package. It trusts the signed +// coordinator replay claim, not a caller flag, and does not claim publication +// or a production GO decision. Historical payload bytes are not in the package. +func VerifyReleaseV4(o VerifyReleaseV4Options) (*VerifyReleaseResult, error) { + trusted, err := loadOperationalCeremony(o.Trust) + if err != nil { + return nil, err + } + d := trusted.Definition + if d.Schema != DefinitionSchemaV4 { + return nil, errors.New("V4 release verifier requires definition v4") + } + public, err := keybundle.DecodePublicKeyHex(o.TrustedPublicKeyHex) + if err != nil { + return nil, err + } + if err := requireIdentityKey(d.ReleaseSigner, public); err != nil { + return nil, err + } + if o.ExpectedSignatureKeyID != d.ReleaseSigner.KeyID { + return nil, errors.New("release signer id differs from signed definition") + } + verifyBundle := keybundle.Verify + if d.Mode == ModeRehearsal && d.Circuit.KeyVersion == KeyVersionRehearsal { + verifyBundle = keybundle.VerifyRehearsal + } + manifest, err := verifyBundle(keybundle.VerifyOptions{KeysDir: o.KeysDir, KeyVersion: d.Circuit.KeyVersion, PublicKeyHex: o.TrustedPublicKeyHex, ExpectedSignatureKeyID: o.ExpectedSignatureKeyID, RequireProvingKey: true}) + if err != nil { + return nil, err + } + pk, err := readRegularFile(filepath.Join(o.KeysDir, keybundle.ManifestPublicKeyFile)) + if err != nil { + return nil, err + } + if strings.TrimSpace(string(pk)) != hex.EncodeToString(public) { + return nil, errors.New("bundled release key differs from trusted key") + } + raw, err := readRegularBounded(filepath.Join(o.KeysDir, FinalTranscriptFile), maxFinalTranscriptV3Bytes) + if err != nil { + return nil, err + } + var transcript FinalTranscript + if err := UnmarshalCanonical(raw, &transcript); err != nil { + return nil, err + } + if transcript.Schema != FinalTranscriptSchemaV3 || transcript.ReleaseReview == nil { + return nil, errors.New("V4 release requires final transcript v3") + } + at, _ := time.Parse(time.RFC3339Nano, transcript.FinalizedAt) + review, err := verifyReleaseReviewV4(o.Trust, o.KeysDir, transcript.ReleaseReview.ReviewCheckpoint, transcript.ReleaseReview.OperationalBundle, at, true) + if err != nil { + return nil, err + } + if !reflect.DeepEqual(review, *transcript.ReleaseReview) { + return nil, errors.New("signed release review differs from verified package") + } + candidate, _, err := verifyCandidate(d, transcript.Definition, o.KeysDir) + if err != nil { + return nil, err + } + expected, err := newFinalTranscriptV3(d, candidate, review) + if err != nil { + return nil, err + } + if !reflect.DeepEqual(transcript, expected) { + return nil, errors.New("final transcript differs from exact verified review and candidate") + } + wantManifest := releaseManifestV4(d, candidate, raw, review.ReleasedAt) + if !reflect.DeepEqual(*manifest, wantManifest) { + return nil, errors.New("manifest differs from exact candidate and review transcript") + } + names, err := releaseDependencyNamesV4(review.RequiredArtifacts) + if err != nil { + return nil, err + } + names = append(names, FinalTranscriptFile, keybundle.ManifestFile, keybundle.ManifestSignatureFile, keybundle.ManifestPublicKeyFile) + if err := verifyChecksumsExactWithLimit(o.KeysDir, filepath.Join(o.KeysDir, ReleaseChecksumsFile), names, maxReleaseChecksumsV4Bytes); err != nil { + return nil, err + } + if err := verifyExactReleaseFiles(o.KeysDir, append(names, ReleaseChecksumsFile), true); err != nil { + return nil, err + } + ref, err := artifactRefForFile(keybundle.ManifestFile, filepath.Join(o.KeysDir, keybundle.ManifestFile)) + if err != nil { + return nil, err + } + return &VerifyReleaseResult{Manifest: manifest, ManifestSHA256: ref.Digest.SHA256, Transcript: transcript, Candidate: candidate}, nil +} diff --git a/internal/mpcceremony/release_v4_software_test.go b/internal/mpcceremony/release_v4_software_test.go new file mode 100644 index 00000000..49214af1 --- /dev/null +++ b/internal/mpcceremony/release_v4_software_test.go @@ -0,0 +1,17 @@ +package mpcceremony + +import ( + "strings" + "testing" +) + +func TestReleaseV4SigningDefinitionBindsReviewAndRunningSoftware(t *testing.T) { + d := trustedCoordinatorDefinition(t) + if err := validateReleaseSigningDefinitionV4(d, ReleaseReviewV4{CeremonyID: "different"}); err == nil || !strings.Contains(err.Error(), "changed after release review") { + t.Fatalf("review binding: %v", err) + } + // This fixture names placeholder software, not the running executable. + if err := validateReleaseSigningDefinitionV4(d, ReleaseReviewV4{CeremonyID: d.CeremonyID}); err == nil || !strings.Contains(err.Error(), "release signing software") { + t.Fatalf("unapproved signer executable: %v", err) + } +} diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go new file mode 100644 index 00000000..11c81db8 --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -0,0 +1,455 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + "slices" + "sort" + "strings" + "time" + + m "proof-tool/internal/mpcceremony" +) + +// Test-only, single-process fixture. Cryptographic contributions and signatures +// are real; environment/cleanup statements are fixtures, not erasure evidence. +func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.CompiledCircuit, d m.CeremonyDefinition, coordinator ed25519.PrivateKey, coordinatorPath string, participant ed25519.PrivateKey, participantPath string) error { + ref := func(name string) (m.ArtifactRef, error) { + b, err := os.ReadFile(filepath.Join(root, name)) // tiny test artifacts only + return m.ArtifactRef{Name: name, Digest: m.NewDigest(b)}, err + } + pair := func(name string) (m.SignedArtifactRefs, error) { + r, err := ref(name + ".json") + if err != nil { + return m.SignedArtifactRefs{}, err + } + s, err := ref(name + ".sig") + return m.SignedArtifactRefs{Record: r, Signature: s}, err + } + writePair := func(name string, value any, keyID string, key ed25519.PrivateKey) (m.SignedArtifactRefs, error) { + r, s, err := m.SignRecord(value, keyID, key) + if err != nil { + return m.SignedArtifactRefs{}, err + } + path := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil { + return m.SignedArtifactRefs{}, err + } + if err := os.WriteFile(path+".json", r, 0600); err != nil { + return m.SignedArtifactRefs{}, err + } + if err := os.WriteFile(path+".sig", s, 0600); err != nil { + return m.SignedArtifactRefs{}, err + } + return pair(name) + } + sorted := func(refs []m.ArtifactRef) []m.ArtifactRef { + refs = append([]m.ArtifactRef{}, refs...) + sort.Slice(refs, func(i, j int) bool { return refs[i].Name < refs[j].Name }) + return refs + } + paths := m.PhaseTranscriptPaths{RootDir: root, ChainPath: filepath.Join(root, "phase1/chain-0000.json"), ChainSignaturePath: filepath.Join(root, "phase1/chain-0000.sig")} + chain, chainRefs, err := m.VerifyAcceptedPhase1Chain(trust, circuit, paths) + if err != nil { + return err + } + head, err := chain.HeadRecordID() + if err != nil { + return err + } + payload, err := chain.HeadPayload() + if err != nil { + return err + } + initial, err := m.PrepareInitialCheckpointV4(m.InitialCheckpointV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: root}) + if err != nil { + return fmt.Errorf("derive initial checkpoint: %w", err) + } + c := initial.Checkpoint + var committed m.SignedArtifactRefs + commit := func() error { + if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: c, Circuit: circuit, RequireCurrentReplayExecutable: true}); err != nil { + return fmt.Errorf("prepare %s: %w", c.Transition.Kind, err) + } + var err error + committed, err = writePair(fmt.Sprintf("checkpoints/%04d", c.Sequence), c, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + _, err = m.VerifyStoredCheckpointV4(trust, root, committed) + return err + } + next := func(tx m.CheckpointTransitionV4) { + previous := committed + c.PreviousCheckpoint = &previous + c.Sequence++ + c.Transition = tx + refs := append([]m.ArtifactRef{}, c.AcceptedArtifacts...) + if tx.Record != nil { + refs = append(refs, tx.Record.Record, tx.Record.Signature) + } + refs = append(refs, tx.Evidence...) + // Different signed records may name the same retained statement. Keep + // the inventory a set; a same-name/different-digest conflict still fails. + unique := make([]m.ArtifactRef, 0, len(refs)) + for _, ref := range refs { + if !slices.Contains(unique, ref) { + unique = append(unique, ref) + } + } + c.AcceptedArtifacts = sorted(unique) + } + if err := commit(); err != nil { + return err + } + p := d.Roster[0].Identity + beforeEnrollment := c + beforeEnrollmentRefs := committed + disclosureName := "enrollments/participant-01/disclosure.txt" + if err := os.MkdirAll(filepath.Join(root, "enrollments/participant-01"), 0700); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, disclosureName), []byte("Test fixture: one process operates every role on one machine.\n"), 0600); err != nil { + return err + } + disclosure, err := ref(disclosureName) + if err != nil { + return err + } + db, err := os.ReadFile(trust.DefinitionPath) + if err != nil { + return err + } + enrollment, err := m.NewEnrollmentRecord(d, db, p, m.EnrollmentParticipant, 1, disclosure, "2023-08-23T15:00:30Z") + if err != nil { + return err + } + enrollmentRefs, err := writePair("enrollments/participant-01/record", enrollment, p.KeyID, participant) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointEnrollmentRecorded, Record: &enrollmentRefs, Evidence: []m.ArtifactRef{disclosure}}) + if err = commit(); err != nil { + return err + } + scope := m.ContributionScope{CeremonyID: d.CeremonyID, Phase: m.Phase1, Index: 1, ParticipantID: p.ID, ParentHeadID: head} + const candidateAttempt = "cccccccccccccccccccccccccccccccc" + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1CandidateAllocated, Scope: &scope, AttemptID: candidateAttempt, AllocatedAt: "2023-08-23T15:01:00Z", Evidence: []m.ArtifactRef{}}) + c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionCandidate, candidateAttempt) + if err != nil { + return err + } + missingEnrollment := c + missingEnrollment.Sequence = beforeEnrollment.Sequence + 1 + missingEnrollment.PreviousCheckpoint = &beforeEnrollmentRefs + missingEnrollment.AcceptedArtifacts = append([]m.ArtifactRef{}, beforeEnrollment.AcceptedArtifacts...) + if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: missingEnrollment, Circuit: circuit}); err == nil || !strings.Contains(err.Error(), "enrollment") { + return fmt.Errorf("allocation without committed enrollment: %v", err) + } + allocated, err := m.PrepareCandidateAllocationCheckpointV4(m.CandidateAllocationCheckpointV4Options{Trust: trust, ArtifactRoot: root, Checkpoint: committed, AttemptID: candidateAttempt, AllocatedAt: "2023-08-23T15:01:00Z"}) + if err != nil { + return err + } + if allocated.Scope != scope { + return errors.New("derived allocation scope differs from signed schedule") + } + c = allocated.Checkpoint + if err = commit(); err != nil { + return err + } + candidateDir := filepath.Join(output, "candidates/v4-turn") + environment := m.ContributionEnvironment{OS: runtime.GOOS, Architecture: runtime.GOARCH, EntropySource: "operating-system-csprng", ContributorSwapDisabled: true, ContributorCrashDumpsDisabled: true, ContributorTelemetryDisabled: true, EphemeralEnvironment: true, EphemeralCleanupRequired: true, HostRemnantsNotExcluded: true} + wrongCandidateDir := candidateDir + "-wrong-attempt" + if _, wrongErr := m.CreateAllocatedContributionCandidateV4(m.AllocatedContributionFilesV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: root, Checkpoint: committed, AttemptID: "dddddddddddddddddddddddddddddddd", ParticipantPrivateKeyPath: participantPath, Environment: environment, ContributedAt: "2023-08-23T15:03:00Z", CandidateDir: wrongCandidateDir}); wrongErr == nil { + return errors.New("unallocated candidate attempt was accepted") + } + if _, statErr := os.Lstat(wrongCandidateDir); !errors.Is(statErr, os.ErrNotExist) { + return fmt.Errorf("rejected allocation wrote candidate output: %v", statErr) + } + if _, err = m.CreateAllocatedContributionCandidateV4(m.AllocatedContributionFilesV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: root, Checkpoint: committed, AttemptID: candidateAttempt, ParticipantPrivateKeyPath: participantPath, Environment: environment, ContributedAt: "2023-08-23T15:03:00Z", CandidateDir: candidateDir}); err != nil { + return err + } + generated, err := m.InspectComputationOutputV4(trust, paths, scope, candidateDir) + if err != nil { + return err + } + if len(generated.Files) != 3 { + return errors.New("preliminary computation inspection did not return three files") + } + if _, err = m.CreateErasureAttestationFiles(m.CreateErasureAttestationFilesOptions{Trust: trust, ParticipantID: p.ID, ParticipantPrivateKeyPath: participantPath, CandidateDir: candidateDir, DestroyedAt: "2023-08-23T15:04:00Z"}); err != nil { + return err + } + computedInventory, err := m.InspectContributionInventoryV4(trust, paths, scope, candidateDir) + if err != nil { + return err + } + if computedInventory.Complete == nil || computedInventory.ComputedCandidateID == "" || computedInventory.CandidateResultID != computedInventory.ComputedCandidateID { + return errors.New("computed inventory reconstruction failed") + } + acceptedCheckpoint, err := m.VerifyAndAcceptAllocatedCandidateV4(m.AcceptAllocatedCandidateV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: root, Checkpoint: committed, AttemptID: candidateAttempt, CandidateDir: candidateDir, CoordinatorPrivateKeyPath: coordinatorPath, AcceptedAt: "2023-08-23T15:05:00Z"}) + if err != nil { + return err + } + accepted := acceptedCheckpoint.Accepted + paths.ChainPath = accepted.ChainPath + paths.ChainSignaturePath = accepted.ChainSignaturePath + chain, chainRefs, err = m.VerifyAcceptedPhase1Chain(trust, circuit, paths) + if err != nil { + return err + } + last := chain.Records[0] + files := []m.ArtifactRef{last.Attestation, last.AttestationSignature, last.OutputPayload, last.Erasure, last.ErasureSignature} + inventory := m.CandidateInventory{Schema: m.CandidateInventorySchemaV1, Scope: scope, Files: append([]m.ArtifactRef{}, files...)} + for i := range inventory.Files { + inventory.Files[i].Name = filepath.Base(inventory.Files[i].Name) + } + if id, err := inventory.ID(); err != nil || id != computedInventory.CandidateResultID { + return errors.New("accepted inventory differs from inspected candidate") + } + acceptedInventoryID, err := acceptedCheckpoint.Candidate.ID() + if err != nil { + return err + } + if acceptedCheckpoint.Scope != scope || acceptedInventoryID != computedInventory.CandidateResultID { + return errors.New("derived acceptance differs from verified candidate") + } + head, err = chain.HeadRecordID() + if err != nil { + return err + } + payload, err = chain.HeadPayload() + if err != nil { + return err + } + c = acceptedCheckpoint.Checkpoint + if err = commit(); err != nil { + return err + } + // Real-file negative: identical length, wrong payload digest must fail before + // a second checkpoint can be prepared. Restore to retain an inspectable run. + file := filepath.Join(root, last.OutputPayload.Name) + b, err := os.ReadFile(file) + if err != nil { + return err + } + changed := append([]byte(nil), b...) + changed[len(changed)-1] ^= 1 + if err = os.WriteFile(file, changed, 0600); err != nil { + return err + } + _, rejectErr := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: c, Circuit: circuit}) + if err = os.WriteFile(file, b, 0600); err != nil { + return err + } + if rejectErr == nil { + return fmt.Errorf("corrupted accepted contribution passed checkpoint preparation") + } + beforeMirrors, beforeMirrorsRefs := c, committed + if d.AssurancePolicy.MirrorsPerAcceptedHead > 0 { + mirrorKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0xb1}, 32)) + mirror, err := m.NewIdentity("mirror-01", "Fixture mirror", "mirror-key", mirrorKey.Public().(ed25519.PublicKey)) + if err != nil { + return err + } + mr, err := m.NewEnrollmentRecord(d, db, mirror, m.EnrollmentMirrorOperator, 1, disclosure, "2023-08-23T15:00:31Z") + if err != nil { + return err + } + mrRefs, err := writePair("enrollments/mirror-01/record", mr, mirror.KeyID, mirrorKey) + if err != nil { + return err + } + // Give this enrollment its own disclosure reference; immutable evidence + // must add precisely its own supporting file rather than re-add a path. + mirrorDisclosureName := "enrollments/mirror-01/disclosure.txt" + if err = os.WriteFile(filepath.Join(root, mirrorDisclosureName), []byte("One-process mirror fixture.\n"), 0600); err != nil { + return err + } + md, err := ref(mirrorDisclosureName) + if err != nil { + return err + } + mr.IndependenceDisclosure = md + mrRefs, err = writePair("enrollments/mirror-01/record", mr, mirror.KeyID, mirrorKey) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointEnrollmentRecorded, Record: &mrRefs, Evidence: []m.ArtifactRef{md}}) + if err = commit(); err != nil { + return err + } + mf, err := m.MirrorReceiptFiles(last, chainRefs) + if err != nil { + return err + } + mirrorReceipt, err := m.NewImmutableMirrorReceipt(d.CeremonyID, m.Phase1, 1, head, mf, mirror, m.NewDigest([]byte("fixture archive")).SHA256, "2023-08-23T15:05:30Z") + if err != nil { + return err + } + mirrorRefs, err := writePair("mirrors/phase1-0001", mirrorReceipt, mirror.KeyID, mirrorKey) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointMirrorRecorded, Record: &mirrorRefs, Evidence: []m.ArtifactRef{}}) + if err = commit(); err != nil { + return err + } + } + // Historical genuine drand response tests binding and mathematics, not a + // live wait. Normal closure commands keep their current-time requirements. + roundTime, err := m.QuicknetRoundTime(42) + if err != nil { + return err + } + participants, err := chain.ParticipantIDs() + if err != nil { + return err + } + closure, err := m.NewCloseRecord(m.CloseRecord{CeremonyID: d.CeremonyID, Phase: m.Phase1, PhaseID: chain.PhaseID, FinalIndex: 1, FinalPayload: payload, ChainHeadID: head, AcceptedParticipants: participants, BeaconProvider: d.BeaconPolicy.Provider, BeaconNetwork: d.BeaconPolicy.Network, BeaconRound: 42, BeaconNotBefore: roundTime.Format(time.RFC3339Nano), ClosedAt: "2023-08-23T15:06:00Z", CoordinatorID: d.Coordinator.ID, CoordinatorKeyID: d.Coordinator.KeyID}) + if err != nil { + return err + } + closureRefs, err := writePair("phase1/closure/record", closure, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1Closed, Record: &closureRefs, Evidence: []m.ArtifactRef{}}) + c.Progress.Phase1Closure = &closureRefs + if d.AssurancePolicy.MirrorsPerAcceptedHead > 0 { + missing := c + missing.Sequence = beforeMirrors.Sequence + 1 + missing.PreviousCheckpoint = &beforeMirrorsRefs + missing.AcceptedArtifacts = sorted(append(append([]m.ArtifactRef{}, beforeMirrors.AcceptedArtifacts...), closureRefs.Record, closureRefs.Signature)) + if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: missing, Circuit: circuit}); err == nil || !strings.Contains(err.Error(), "mirror") { + return fmt.Errorf("closure without required mirror: %v", err) + } + } + if err = commit(); err != nil { + return err + } + raw := filepath.Join(output, "quicknet-v4-42.json") + if d.AssurancePolicy.PublicWitnessesPerPhase > 0 { + witnessKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0xa1}, 32)) + witness, err := m.NewIdentity("witness-01", "Fixture witness", "witness-key", witnessKey.Public().(ed25519.PublicKey)) + if err != nil { + return err + } + if err = os.MkdirAll(filepath.Join(root, "enrollments/witness-01"), 0700); err != nil { + return err + } + wdName := "enrollments/witness-01/disclosure.txt" + if err = os.WriteFile(filepath.Join(root, wdName), []byte("One-process witness fixture, not independent observation.\n"), 0600); err != nil { + return err + } + wd, err := ref(wdName) + if err != nil { + return err + } + wr, err := m.NewEnrollmentRecord(d, db, witness, m.EnrollmentPublicWitness, 1, wd, "2023-08-23T15:00:32Z") + if err != nil { + return err + } + wrRefs, err := writePair("enrollments/witness-01/record", wr, witness.KeyID, witnessKey) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointEnrollmentRecorded, Record: &wrRefs, Evidence: []m.ArtifactRef{wd}}) + if err = commit(); err != nil { + return err + } + receipt := m.PublicWitnessReceipt{Schema: m.PublicWitnessReceiptSchema, CeremonyID: d.CeremonyID, Phase: m.Phase1, CloseID: closure.CloseID, ChainHeadID: closure.ChainHeadID, Closure: closureRefs.Record, BeaconRound: 42, BeaconScheduledAt: roundTime.Format(time.RFC3339Nano), PublicationLocationSHA: m.NewDigest([]byte("fixture publication")).SHA256, Witness: witness, ObservedAt: "2023-08-23T15:06:30Z"} + wrRefs, err = writePair("witnesses/phase1", receipt, witness.KeyID, witnessKey) + if err != nil { + return err + } + if os.Getenv("MPC_WORKFLOW_SKIP_WITNESS") != "1" { + next(m.CheckpointTransitionV4{Kind: m.CheckpointWitnessRecorded, Record: &wrRefs, Evidence: []m.ArtifactRef{}}) + if err = commit(); err != nil { + return err + } + } + } + if err = os.WriteFile(raw, []byte(quicknetRound42), 0600); err != nil { + return err + } + beacon, err := m.RecordBeaconFiles(m.RecordBeaconFilesOptions{Trust: trust, TranscriptRoot: root, Phase: m.Phase1, ClosePath: filepath.Join(root, closureRefs.Record.Name), CloseSignaturePath: filepath.Join(root, closureRefs.Signature.Name), RawResponsePath: raw, PublishedAt: "2023-08-23T15:11:30Z", CoordinatorPrivateKeyPath: coordinatorPath}) + if err != nil { + return err + } + beaconName, err := filepath.Rel(root, beacon.BeaconPath) + if err != nil { + return err + } + beaconSignatureName, err := filepath.Rel(root, beacon.SignaturePath) + if err != nil { + return err + } + br, err := ref(filepath.ToSlash(beaconName)) + if err != nil { + return err + } + bs, err := ref(filepath.ToSlash(beaconSignatureName)) + if err != nil { + return err + } + beaconRefs := m.SignedArtifactRefs{Record: br, Signature: bs} + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1BeaconRecorded, Record: &beaconRefs, Evidence: []m.ArtifactRef{beacon.Beacon.RawResponse}}) + c.Progress.Phase1Beacon = &beaconRefs + if err = commit(); err != nil { + return err + } + seal, err := m.SealPhase1Files(m.SealPhase1FilesOptions{Trust: trust, Circuit: circuit, TranscriptRoot: root, ClosePath: filepath.Join(root, closureRefs.Record.Name), CloseSignaturePath: filepath.Join(root, closureRefs.Signature.Name), BeaconPath: beacon.BeaconPath, BeaconSignaturePath: beacon.SignaturePath, CoordinatorPrivateKeyPath: coordinatorPath, OutputDir: filepath.Join(root, "phase1/sealed")}) + if err != nil { + return err + } + sealName, err := filepath.Rel(root, seal.SealPath) + if err != nil { + return err + } + sealSigName, err := filepath.Rel(root, seal.SignaturePath) + if err != nil { + return err + } + sr, err := ref(filepath.ToSlash(sealName)) + if err != nil { + return err + } + ss, err := ref(filepath.ToSlash(sealSigName)) + if err != nil { + return err + } + sealRefs := m.SignedArtifactRefs{Record: sr, Signature: ss} + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1Sealed, Record: &sealRefs, Evidence: seal.Seal.Outputs}) + c.Progress.Phase1Seal = &sealRefs + if err = commit(); err != nil { + return err + } + p2, err := m.InitializePhase2Files(m.InitPhase2FilesOptions{Trust: trust, Circuit: circuit, TranscriptRoot: root, Phase1SealPath: seal.SealPath, Phase1SealSignaturePath: seal.SignaturePath, CoordinatorPrivateKeyPath: coordinatorPath, OutputDir: filepath.Join(root, "phase2")}) + if err != nil { + return err + } + p2Chain, p2Refs, err := m.VerifyAcceptedPhase2Chain(trust, circuit, root, seal.SealPath, seal.SignaturePath, m.PhaseTranscriptPaths{RootDir: root, ChainPath: p2.ChainPath, ChainSignaturePath: p2.ChainSignaturePath}) + if err != nil { + return err + } + p2Head, err := p2Chain.HeadRecordID() + if err != nil { + return err + } + p2Payload, err := p2Chain.HeadPayload() + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2Initialized, Record: &p2Refs, Evidence: []m.ArtifactRef{p2Payload}}) + c.Progress.Phase2 = &m.CheckpointPhaseState{Phase: m.Phase2, HeadRecordID: p2Head, HeadPayload: p2Payload, Chain: p2Refs} + if err = commit(); err != nil { + return err + } + fmt.Println("V4 real phase1 turn passed: initial, allocation, contribution, cleanup, full replay, exact acceptance, corruption rejected, closure, drand, seal, phase2 genesis") + return runCheckpointV4Final(output, root, trust, circuit, d, coordinator, coordinatorPath, participant, participantPath, &c, next, commit, writePair, ref, sorted) +} diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go new file mode 100644 index 00000000..4a4c8e54 --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -0,0 +1,575 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "fmt" + "os" + "path/filepath" + "runtime" + "strings" + "time" + + m "proof-tool/internal/mpcceremony" +) + +// Continuation of the same real cryptographic fixture. Historical times and +// single-process observer statements do not establish a live ceremony. +func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.CompiledCircuit, d m.CeremonyDefinition, coordinator ed25519.PrivateKey, coordinatorPath string, participant ed25519.PrivateKey, participantPath string, c *m.CheckpointV4, + next func(m.CheckpointTransitionV4), commit func() error, + writePair func(string, any, string, ed25519.PrivateKey) (m.SignedArtifactRefs, error), + ref func(string) (m.ArtifactRef, error), sorted func([]m.ArtifactRef) []m.ArtifactRef) error { + p := d.Roster[0].Identity + scope := m.ContributionScope{CeremonyID: d.CeremonyID, Phase: m.Phase2, Index: 1, ParticipantID: p.ID, ParentHeadID: c.Progress.Phase2.HeadRecordID} + path := func(r m.ArtifactRef) string { return filepath.Join(root, r.Name) } + seal := *c.Progress.Phase1Seal + paths := m.PhaseTranscriptPaths{RootDir: root, ChainPath: path(c.Progress.Phase2.Chain.Record), ChainSignaturePath: path(c.Progress.Phase2.Chain.Signature)} + const candidateAttempt = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2CandidateAllocated, Scope: &scope, AttemptID: candidateAttempt, AllocatedAt: "2023-08-23T15:11:30.1Z", Evidence: []m.ArtifactRef{}}) + var err error + c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionCandidate, candidateAttempt) + if err != nil { + return err + } + if err = commit(); err != nil { + return err + } + checkpointRecord, err := ref(fmt.Sprintf("checkpoints/%04d.json", c.Sequence)) + if err != nil { + return err + } + checkpointSignature, err := ref(fmt.Sprintf("checkpoints/%04d.sig", c.Sequence)) + if err != nil { + return err + } + checkpoint := m.SignedArtifactRefs{Record: checkpointRecord, Signature: checkpointSignature} + candidateDir := filepath.Join(output, "candidates/v4-phase2") + environment := m.ContributionEnvironment{OS: runtime.GOOS, Architecture: runtime.GOARCH, EntropySource: "operating-system-csprng", ContributorSwapDisabled: true, ContributorCrashDumpsDisabled: true, ContributorTelemetryDisabled: true, EphemeralEnvironment: true, EphemeralCleanupRequired: true, HostRemnantsNotExcluded: true} + if _, err = m.CreateAllocatedContributionCandidateV4(m.AllocatedContributionFilesV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: root, Checkpoint: checkpoint, AttemptID: candidateAttempt, ParticipantPrivateKeyPath: participantPath, Environment: environment, ContributedAt: "2023-08-23T15:11:30.3Z", CandidateDir: candidateDir}); err != nil { + return err + } + if _, err = m.CreateErasureAttestationFiles(m.CreateErasureAttestationFilesOptions{Trust: trust, ParticipantID: p.ID, ParticipantPrivateKeyPath: participantPath, CandidateDir: candidateDir, DestroyedAt: "2023-08-23T15:11:30.4Z"}); err != nil { + return err + } + files := []m.ArtifactRef{} + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} { + b, err := os.ReadFile(filepath.Join(candidateDir, name)) + if err != nil { + return err + } + files = append(files, m.ArtifactRef{Name: "phase2/contributions/0001/" + name, Digest: m.NewDigest(b)}) + } + accepted, err := m.VerifyAndAcceptContribution(m.AcceptContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase2, Transcript: paths, Phase1SealPath: path(seal.Record), Phase1SealSignaturePath: path(seal.Signature), CandidateDir: candidateDir, CoordinatorPrivateKeyPath: coordinatorPath, AcceptedAt: "2023-08-23T15:11:30.5Z"}) + if err != nil { + return err + } + paths.ChainPath, paths.ChainSignaturePath = accepted.ChainPath, accepted.ChainSignaturePath + chain, chainRefs, err := m.VerifyAcceptedPhase2Chain(trust, circuit, root, path(seal.Record), path(seal.Signature), paths) + if err != nil { + return err + } + last := chain.Records[0] + files = []m.ArtifactRef{last.Attestation, last.AttestationSignature, last.OutputPayload, last.Erasure, last.ErasureSignature} + inv := m.CandidateInventory{Schema: m.CandidateInventorySchemaV1, Scope: scope, Files: append([]m.ArtifactRef{}, files...)} + for i := range inv.Files { + inv.Files[i].Name = filepath.Base(inv.Files[i].Name) + } + evidence := append([]m.ArtifactRef{}, files...) + evidence = append(evidence, last.Verification) + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2CandidateAccepted, Scope: &scope, AttemptID: candidateAttempt, Record: &chainRefs, Evidence: sorted(evidence), Contribution: &inv}) + c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, candidateAttempt, m.DeliveryAccepted, &inv) + if err != nil { + return err + } + head, err := chain.HeadRecordID() + if err != nil { + return err + } + payload, err := chain.HeadPayload() + if err != nil { + return err + } + c.Progress.Phase2 = &m.CheckpointPhaseState{Phase: m.Phase2, AcceptedCount: 1, HeadRecordID: head, HeadPayload: payload, Chain: chainRefs} + if err = commit(); err != nil { + return err + } + if d.AssurancePolicy.MirrorsPerAcceptedHead > 0 { + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0xb1}, 32)) + identity, err := m.NewIdentity("mirror-01", "Fixture mirror", "mirror-key", key.Public().(ed25519.PublicKey)) + if err != nil { + return err + } + mf, err := m.MirrorReceiptFiles(last, chainRefs) + if err != nil { + return err + } + mr, err := m.NewImmutableMirrorReceipt(d.CeremonyID, m.Phase2, 1, head, mf, identity, m.NewDigest([]byte("fixture archive phase2")).SHA256, "2023-08-23T15:11:30.6Z") + if err != nil { + return err + } + refs, err := writePair("mirrors/phase2-0001", mr, identity.KeyID, key) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointMirrorRecorded, Record: &refs, Evidence: []m.ArtifactRef{}}) + if err = commit(); err != nil { + return err + } + } + roundTime, err := m.QuicknetRoundTime(43) + if err != nil { + return err + } + participants, err := chain.ParticipantIDs() + if err != nil { + return err + } + closure, err := m.NewCloseRecord(m.CloseRecord{CeremonyID: d.CeremonyID, Phase: m.Phase2, PhaseID: chain.PhaseID, FinalIndex: 1, FinalPayload: payload, ChainHeadID: head, AcceptedParticipants: participants, BeaconProvider: d.BeaconPolicy.Provider, BeaconNetwork: d.BeaconPolicy.Network, BeaconRound: 43, BeaconNotBefore: roundTime.Format(time.RFC3339Nano), ClosedAt: "2023-08-23T15:11:30.7Z", CoordinatorID: d.Coordinator.ID, CoordinatorKeyID: d.Coordinator.KeyID}) + if err != nil { + return err + } + cr, err := writePair("phase2/closure/record", closure, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2Closed, Record: &cr, Evidence: []m.ArtifactRef{}}) + c.Progress.Phase2Closure = &cr + for _, badCase := range []struct { + round uint64 + closed, want string + }{ + {42, "2023-08-23T15:11:29Z", "later beacon round"}, + {41, "2023-08-23T15:11:26Z", "later beacon round"}, + {43, "2023-08-23T15:11:30Z", "follow phase1 beacon publication"}, + } { + badClose := closure + badClose.BeaconRound = badCase.round + badClose.ClosedAt = badCase.closed + when, err := m.QuicknetRoundTime(badCase.round) + if err != nil { + return err + } + badClose.BeaconNotBefore = when.Format(time.RFC3339Nano) + badClose, err = m.NewCloseRecord(badClose) + if err != nil { + return err + } + badRefs, err := writePair("phase2/closure/record", badClose, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + bad := *c + bad.Transition.Record = &badRefs + bad.Progress.Phase2Closure = &badRefs + bad.AcceptedArtifacts = append([]m.ArtifactRef{}, c.AcceptedArtifacts...) + for i, r := range bad.AcceptedArtifacts { + if r.Name == badRefs.Record.Name { + bad.AcceptedArtifacts[i] = badRefs.Record + } + if r.Name == badRefs.Signature.Name { + bad.AcceptedArtifacts[i] = badRefs.Signature + } + } + _, reject := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: bad, Circuit: circuit}) + if reject == nil || !strings.Contains(reject.Error(), badCase.want) { + return fmt.Errorf("signed bad phase2 closure: want %s, got %v", badCase.want, reject) + } + } + if _, err = writePair("phase2/closure/record", closure, d.Coordinator.KeyID, coordinator); err != nil { + return err + } + if err = commit(); err != nil { + return err + } + if d.AssurancePolicy.PublicWitnessesPerPhase > 0 { + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0xa1}, 32)) + identity, err := m.NewIdentity("witness-01", "Fixture witness", "witness-key", key.Public().(ed25519.PublicKey)) + if err != nil { + return err + } + wr := m.PublicWitnessReceipt{Schema: m.PublicWitnessReceiptSchema, CeremonyID: d.CeremonyID, Phase: m.Phase2, CloseID: closure.CloseID, ChainHeadID: head, Closure: cr.Record, BeaconRound: 43, BeaconScheduledAt: roundTime.Format(time.RFC3339Nano), PublicationLocationSHA: m.NewDigest([]byte("fixture publication phase2")).SHA256, Witness: identity, ObservedAt: "2023-08-23T15:11:30.8Z"} + refs, err := writePair("witnesses/phase2", wr, identity.KeyID, key) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointWitnessRecorded, Record: &refs, Evidence: []m.ArtifactRef{}}) + if err = commit(); err != nil { + return err + } + } + raw := filepath.Join(output, "quicknet-v4-43.json") + if err = os.WriteFile(raw, []byte(quicknetRound43), 0600); err != nil { + return err + } + beacon, err := m.RecordBeaconFiles(m.RecordBeaconFilesOptions{Trust: trust, TranscriptRoot: root, Phase: m.Phase2, ClosePath: path(cr.Record), CloseSignaturePath: path(cr.Signature), RawResponsePath: raw, PublishedAt: "2023-08-23T15:11:33Z", CoordinatorPrivateKeyPath: coordinatorPath}) + if err != nil { + return err + } + br, err := ref("phase2/beacon/record.json") + if err != nil { + return err + } + bs, err := ref("phase2/beacon/record.sig") + if err != nil { + return err + } + brefs := m.SignedArtifactRefs{Record: br, Signature: bs} + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2BeaconRecorded, Record: &brefs, Evidence: []m.ArtifactRef{beacon.Beacon.RawResponse}}) + c.Progress.Phase2Beacon = &brefs + if err = commit(); err != nil { + return err + } + pr := c.Progress + replay := m.ReplayPaths{TranscriptRoot: root, CoordinatorPublicKeyHex: d.Coordinator.Ed25519PublicKeyHex, DefinitionPath: trust.DefinitionPath, DefinitionSignaturePath: trust.DefinitionSignaturePath, Phase1ChainPath: path(pr.Phase1.Chain.Record), Phase1ChainSignaturePath: path(pr.Phase1.Chain.Signature), Phase1ClosePath: path(pr.Phase1Closure.Record), Phase1CloseSignaturePath: path(pr.Phase1Closure.Signature), Phase1BeaconPath: path(pr.Phase1Beacon.Record), Phase1BeaconSignaturePath: path(pr.Phase1Beacon.Signature), Phase1SealPath: path(pr.Phase1Seal.Record), Phase1SealSignaturePath: path(pr.Phase1Seal.Signature), Phase2ChainPath: path(pr.Phase2.Chain.Record), Phase2ChainSignaturePath: path(pr.Phase2.Chain.Signature), Phase2ClosePath: path(pr.Phase2Closure.Record), Phase2CloseSignaturePath: path(pr.Phase2Closure.Signature), Phase2BeaconPath: path(pr.Phase2Beacon.Record), Phase2BeaconSignaturePath: path(pr.Phase2Beacon.Signature)} + preliminary := filepath.Join(output, "v4-preliminary") + if _, err = m.PrepareFinalization(m.PrepareFinalizationOptions{Replay: replay, Circuit: circuit, OutDir: preliminary, CoordinatorSigningKey: coordinatorPath, PreparedAt: mustUTC("2023-08-23T15:11:34Z")}); err != nil { + return err + } + publicEvidence := filepath.Join(output, "v4-public-evidence.json") + if err = writeTinyPublicEvidence(publicEvidence, d.CeremonyID, circuit, preliminary); err != nil { + return err + } + final := filepath.Join(root, "final/candidate") + if err = os.MkdirAll(filepath.Dir(final), 0700); err != nil { + return err + } + if _, err = m.Finalize(m.FinalizeOptions{Replay: replay, Circuit: circuit, OutDir: final, CoordinatorSigningKey: coordinatorPath, PublicEvidencePath: publicEvidence, FinalizedAt: mustUTC("2023-08-23T15:11:35Z")}); err != nil { + return err + } + _, refs, err := m.VerifyFinalCandidateCheckpoint(replay, circuit, final) + if err != nil { + return err + } + var finalRefs m.SignedArtifactRefs + evidence = []m.ArtifactRef{} + for _, r := range refs { + r.Name = "final/candidate/" + r.Name + switch filepath.Base(r.Name) { + case m.CandidateMetadataFile: + finalRefs.Record = r + case m.CandidateSignatureFile: + finalRefs.Signature = r + default: + evidence = append(evidence, r) + } + } + running, err := m.RunningSoftwareBindingForMode(d.Software.ProofToolVersion, d.Mode) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointFinalCandidateRecorded, Record: &finalRefs, Evidence: sorted(evidence), ReplayVerification: &m.CheckpointReplayVerificationV4{Method: m.CoordinatorReplayReleaseV1, ToolBinary: running.ToolBinary}}) + c.Progress.FinalCandidate = &finalRefs + if err = commit(); err != nil { + return err + } + // Unsuccessful authoring attempts below are never signed or stored. + for _, mutation := range []string{"missing-method", "wrong-executable", "missing-file"} { + bad := *c + tx := bad.Transition + claim := *tx.ReplayVerification + tx.ReplayVerification = &claim + switch mutation { + case "missing-method": + claim.Method = "" + case "wrong-executable": + claim.ToolBinary = m.NewDigest([]byte("not the approved verifier")) + case "missing-file": + tx.Evidence = append([]m.ArtifactRef{}, tx.Evidence[1:]...) + } + bad.Transition = tx + if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: bad, Circuit: circuit}); err == nil { + return fmt.Errorf("final candidate accepted %s", mutation) + } + } + extra := filepath.Join(final, "unexpected.txt") + if err = os.WriteFile(extra, []byte("not in the approved candidate"), 0600); err != nil { + return err + } + _, extraErr := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: *c, Circuit: circuit}) + if err = os.Remove(extra); err != nil { + return err + } + if extraErr == nil { + return fmt.Errorf("final candidate accepted an extra file") + } + fmt.Println("V4 phase2 and final candidate passed: real contribution, cleanup, optional observers, second drand round, coordinator full replay, exact final inventory") + if d.AssurancePolicy.PassingCeremonyAudits > 0 { + db, err := os.ReadFile(trust.DefinitionPath) + if err != nil { + return err + } + for i, identity := range d.Auditors { + beforeEnrollment := *c + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{byte(0x83 + i)}, 32)) + keyPath := filepath.Join(output, "identity-keys", identity.ID+".ed25519.private.hex") + name := "enrollments/" + identity.ID + "/disclosure.txt" + if err = os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0700); err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, name), []byte("Single-process audit fixture, not independent operators.\n"), 0600); err != nil { + return err + } + disclosure, err := ref(name) + if err != nil { + return err + } + enrollment, err := m.NewEnrollmentRecord(d, db, identity, m.EnrollmentAuditor, uint16(i+1), disclosure, "2023-08-23T15:11:36Z") + if err != nil { + return err + } + er, err := writePair("enrollments/"+identity.ID+"/record", enrollment, identity.KeyID, key) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointEnrollmentRecorded, Record: &er, Evidence: []m.ArtifactRef{disclosure}}) + beforeEnrollmentRef := *c.PreviousCheckpoint + if err = commit(); err != nil { + return err + } + name = "audits/" + identity.ID + if err = os.MkdirAll(filepath.Join(root, "audits"), 0700); err != nil { + return err + } + if _, err = m.Audit(m.AuditOptions{Replay: replay, Circuit: circuit, CandidateDir: final, AuditorID: identity.ID, AuditorSigningKey: keyPath, OutPath: filepath.Join(root, name+".json"), SignatureOutPath: filepath.Join(root, name+".sig"), AuditedAt: mustUTC("2023-08-23T15:11:37Z")}); err != nil { + return err + } + r, err := ref(name + ".json") + if err != nil { + return err + } + s, err := ref(name + ".sig") + if err != nil { + return err + } + ar := m.SignedArtifactRefs{Record: r, Signature: s} + next(m.CheckpointTransitionV4{Kind: m.CheckpointAuditRecorded, Record: &ar, Evidence: []m.ArtifactRef{}}) + missing := *c + missing.Sequence = beforeEnrollment.Sequence + 1 + missing.PreviousCheckpoint = &beforeEnrollmentRef + missing.AcceptedArtifacts = sorted(append(append([]m.ArtifactRef{}, beforeEnrollment.AcceptedArtifacts...), ar.Record, ar.Signature)) + if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: missing, Circuit: circuit}); err == nil || !strings.Contains(err.Error(), "committed auditor enrollment") { + return fmt.Errorf("audit without enrollment: %v", err) + } + if err = commit(); err != nil { + return err + } + } + fmt.Println("V4 audits passed: two real replays, committed enrollment, partial collection then full minimum") + } + headRefs := func() (m.SignedArtifactRefs, error) { + name := fmt.Sprintf("checkpoints/%04d", c.Sequence) + r, err := ref(name + ".json") + if err != nil { + return m.SignedArtifactRefs{}, err + } + s, err := ref(name + ".sig") + return m.SignedArtifactRefs{Record: r, Signature: s}, err + } + db, err := os.ReadFile(trust.DefinitionPath) + if err != nil { + return err + } + for _, owner := range []struct { + identity m.Identity + role m.EnrollmentRole + index uint16 + seed byte + }{ + {d.Coordinator, m.EnrollmentCoordinator, 1, 0x81}, + {d.ReleaseSigner, m.EnrollmentReleaseSigner, 1, 0x82}, + {d.Roster[1].Identity, m.EnrollmentParticipant, 2, 0x92}, + } { + head, err := headRefs() + if err != nil { + return err + } + missing, err := m.PrepareOperationalBundleV4(trust, root, head, mustUTC("2023-08-23T15:11:38Z")) + if err == nil || !strings.Contains(err.Error(), "required proof-of-possession enrollment") || missing.Bundle.Schema != "" { + return fmt.Errorf("missing required %s enrollment did not block bundle: %v", owner.identity.ID, err) + } + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{owner.seed}, 32)) + name := "enrollments/" + owner.identity.ID + "/disclosure.txt" + if err = os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0700); err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, name), []byte("One process controls every fixture role; no independence claim.\n"), 0600); err != nil { + return err + } + disclosure, err := ref(name) + if err != nil { + return err + } + record, err := m.NewEnrollmentRecord(d, db, owner.identity, owner.role, owner.index, disclosure, "2023-08-23T15:11:37.5Z") + if err != nil { + return err + } + pair, err := writePair("enrollments/"+owner.identity.ID+"/record", record, owner.identity.KeyID, key) + if err != nil { + return err + } + // Even a valid signed enrollment already on disk is not committed until + // the coordinator adds it to the authenticated checkpoint history. + loose, looseErr := m.PrepareOperationalBundleV4(trust, root, head, mustUTC("2023-08-23T15:11:38Z")) + if looseErr == nil || !strings.Contains(looseErr.Error(), "required proof-of-possession enrollment") || loose.Bundle.Schema != "" { + return fmt.Errorf("loose uncommitted enrollment was used: %v", looseErr) + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointEnrollmentRecorded, Record: &pair, Evidence: []m.ArtifactRef{disclosure}}) + if err = commit(); err != nil { + return err + } + } + // Incidents are public, explicitly selected evidence, not automatic logs. + if err = os.MkdirAll(filepath.Join(root, "governance"), 0700); err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, "governance/statement.txt"), []byte("Single-process rehearsal fixture; no independent operators or erasure evidence.\n"), 0600); err != nil { + return err + } + statement, err := ref("governance/statement.txt") + if err != nil { + return err + } + incident := m.GovernanceRecord{Schema: m.GovernanceRecordSchema, Kind: m.GovernanceIncident, CeremonyID: d.CeremonyID, Phase: m.Phase2, Index: c.Progress.Phase2.AcceptedCount, HeadID: c.Progress.Phase2.HeadRecordID, Evidence: []m.ArtifactRef{statement}, ReasonCode: "fixture-notice", StatementSHA256: statement.Digest.SHA256, SignerID: d.Coordinator.ID, SignerKeyID: d.Coordinator.KeyID, RecordedAt: "2023-08-23T15:11:37.7Z"} + ir, err := writePair("governance/incident", incident, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + beforeIncident := *c + wrongIncident := incident + wrongIncident.HeadID = c.Progress.Phase1.HeadRecordID + wrongPair, err := writePair("governance/wrong-head", wrongIncident, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointIncidentRecorded, Record: &wrongPair, Evidence: []m.ArtifactRef{statement}}) + // Deliberately bypass PrepareCheckpointV4: later inspection and bundle + // preparation must still catch a signed but semantically wrong head. + wrongCheckpoint, err := writePair("governance/wrong-checkpoint", *c, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + badBundle, rejected := m.PrepareOperationalBundleV4(trust, root, wrongCheckpoint, mustUTC("2023-08-23T15:11:38Z")) + if rejected == nil || !strings.Contains(rejected.Error(), "exact current phase and head") || badBundle.Bundle.Schema != "" { + return fmt.Errorf("signed wrong-head incident accepted: %v", rejected) + } + *c = beforeIncident + next(m.CheckpointTransitionV4{Kind: m.CheckpointIncidentRecorded, Record: &ir, Evidence: []m.ArtifactRef{statement}}) + if err = commit(); err != nil { + return err + } + checkpoint, err = headRefs() + if err != nil { + return err + } + prepared, err := m.PrepareOperationalBundleV4(trust, root, checkpoint, mustUTC("2023-08-23T15:11:38Z")) + if err != nil { + return err + } + if len(prepared.Bundle.GovernanceRecords) != 1 || prepared.Bundle.GovernanceRecords[0] != ir { + return fmt.Errorf("committed incident missing from bundle") + } + again, err := m.PrepareOperationalBundleV4(trust, root, checkpoint, mustUTC("2023-08-23T15:11:38Z")) + if err != nil { + return err + } + preparedBytes, err := m.MarshalCanonical(prepared) + if err != nil { + return err + } + againBytes, err := m.MarshalCanonical(again) + if err != nil { + return err + } + if !bytes.Equal(preparedBytes, againBytes) || prepared.SourceCheckpoint != checkpoint { + return fmt.Errorf("bundle derivation was not byte-identical for the same checkpoint and time") + } + for _, target := range []string{prepared.Bundle.Phase1.AcceptedHeads[0].AcceptedChainPrefix.Record.Name, prepared.Bundle.Phase2.AcceptedHeads[0].AcceptedChainPrefix.Record.Name, prepared.Bundle.Phase2.RawBeaconResponses[0].Name} { + original, err := os.ReadFile(filepath.Join(root, target)) + if err != nil { + return err + } + corrupted := bytes.Clone(original) + corrupted[len(corrupted)-1] ^= 1 + if err = os.WriteFile(filepath.Join(root, target), corrupted, 0600); err != nil { + return err + } + bad, reject := m.PrepareOperationalBundleV4(trust, root, checkpoint, mustUTC("2023-08-23T15:11:38Z")) + if err = os.WriteFile(filepath.Join(root, target), original, 0600); err != nil { + return err + } + if reject == nil || bad.Bundle.Schema != "" { + return fmt.Errorf("corrupted bundle input %s was not rejected", target) + } + } + brs, err := writePair("operational/evidence-bundle", prepared.Bundle, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + bb, err := os.ReadFile(path(brs.Record)) + if err != nil { + return err + } + sig, err := os.ReadFile(path(brs.Signature)) + if err != nil { + return err + } + first, err := m.LoadAuthenticatedCloseEvidence(root, prepared.Bundle.Phase1.Close) + if err != nil { + return err + } + second, err := m.LoadAuthenticatedCloseEvidence(root, prepared.Bundle.Phase2.Close) + if err != nil { + return err + } + if _, err = m.VerifyOperationalEvidenceBundle(m.VerifyOperationalEvidenceOptions{Definition: d, CoordinatorPublicKey: coordinator.Public().(ed25519.PublicKey), EvidenceRoot: root, BundleBytes: bb, BundleSignatureBytes: sig, Phase1Close: first, Phase2Close: second}); err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointReleaseReviewRecorded, Record: &brs, Evidence: []m.ArtifactRef{}}) + c.Progress.ReleaseReview = &brs + if err = commit(); err != nil { + return err + } + checkpoint, err = headRefs() + if err != nil { + return err + } + fmt.Println("V4 operational bundle passed: deterministic checkpoint-only assembly, all roster enrollments, original bundle verifier, corruption rejected") + if err := runCheckpointV4Review(root, trust, d, *c, checkpoint, brs, coordinator); err != nil { + return err + } + beforeStop := *c + stop := incident + stop.Kind = m.GovernanceAbort + stop.ReasonCode = "fixture-stop" + stopPair, err := writePair("governance/abort", stop, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointAborted, Record: &stopPair, Evidence: []m.ArtifactRef{statement}}) + c.Progress.Terminal = &m.CheckpointTerminalV4{Kind: m.GovernanceAbort, Record: stopPair} + // Stopping must not require an unrelated retained contribution payload. + stopPayloadPath := path(c.Progress.Phase1.HeadPayload) + if err = os.Rename(stopPayloadPath, stopPayloadPath+".stop-test"); err != nil { + return err + } + _, stopErr := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: *c, Circuit: circuit}) + if err = os.Rename(stopPayloadPath+".stop-test", stopPayloadPath); err != nil { + return err + } + if stopErr != nil { + return fmt.Errorf("stop blocked by unrelated missing payload: %w", stopErr) + } + stopped, err := writePair("governance/terminal-checkpoint", *c, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + if _, err = m.VerifyStoredCheckpointV4(trust, root, stopped); err != nil { + return err + } + if result, err := m.PrepareOperationalBundleV4(trust, root, stopped, mustUTC("2023-08-23T15:11:38Z")); err == nil || result.Bundle.Schema != "" { + return fmt.Errorf("terminal checkpoint allowed release bundle: %v", err) + } + *c = beforeStop + fmt.Println("V4 terminal branch passed: authenticated abort, missing unrelated payload, no release bundle") + return nil +} diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release.go new file mode 100644 index 00000000..d0e0c43f --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release.go @@ -0,0 +1,167 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "proof-tool/internal/artifact" + "proof-tool/internal/keybundle" + m "proof-tool/internal/mpcceremony" +) + +func runCheckpointV4Release(root string, trust m.TrustPaths, d m.CeremonyDefinition, review m.ReleaseReviewV4, key, out string) error { + at, _ := time.Parse(time.RFC3339Nano, review.ReleasedAt) + options := m.SignReleaseV4Options{Trust: trust, ArtifactRoot: root, ReviewCheckpoint: review.ReviewCheckpoint, OperationalBundle: review.OperationalBundle, ReleaseDir: out, ReleaseSigningKey: key, SignatureKeyID: d.ReleaseSigner.KeyID, ReleasedAt: at} + wrong := options + wrong.SignatureKeyID = d.Coordinator.KeyID + if _, err := m.SignReleaseV4(wrong); err == nil { + return fmt.Errorf("wrong release signer id accepted") + } + if _, err := os.Lstat(out); !os.IsNotExist(err) { + return fmt.Errorf("failed release published output: %v", err) + } + if _, err := m.SignReleaseV4(options); err != nil { + return fmt.Errorf("V4 release signing: %w", err) + } + verify := m.VerifyReleaseV4Options{Trust: trust, KeysDir: out, TrustedPublicKeyHex: d.ReleaseSigner.Ed25519PublicKeyHex, ExpectedSignatureKeyID: d.ReleaseSigner.KeyID} + if _, err := m.VerifyReleaseV4(verify); err != nil { + return fmt.Errorf("V4 release verification: %w", err) + } + if _, err := keybundle.VerifyRehearsal(keybundle.VerifyOptions{KeysDir: out, KeyVersion: d.Circuit.KeyVersion, PublicKeyHex: d.ReleaseSigner.Ed25519PublicKeyHex, ExpectedSignatureKeyID: d.ReleaseSigner.KeyID, RequireProvingKey: true}); err != nil { + return fmt.Errorf("ordinary rehearsal key-bundle consumer: %w", err) + } + if _, err := m.VerifyRelease(m.VerifyReleaseOptions{DefinitionPath: trust.DefinitionPath, DefinitionSignaturePath: trust.DefinitionSignaturePath, CoordinatorPublicKeyHex: d.Coordinator.Ed25519PublicKeyHex, KeysDir: out, TrustedPublicKeyHex: d.ReleaseSigner.Ed25519PublicKeyHex, ExpectedSignatureKeyID: d.ReleaseSigner.KeyID, RequireProvingKey: true}); err == nil { + return fmt.Errorf("legacy release verifier accepted definition v4") + } + if _, err := m.SignReleaseV4(options); err != nil { + return fmt.Errorf("exact V4 signing retry: %w", err) + } + if _, err := os.Lstat(filepath.Join(out, "final/candidate")); !os.IsNotExist(err) { + return fmt.Errorf("V4 package duplicates candidate directory: %v", err) + } + for _, name := range []string{m.NativeVerifyingKeyFile, review.ReviewCheckpoint.Signature.Name, m.OperationalEvidenceSignatureFile} { + p := filepath.Join(out, name) + original, err := os.ReadFile(p) + if err != nil { + return err + } + bad := bytes.Clone(original) + bad[len(bad)-1] ^= 1 + if err := os.WriteFile(p, bad, 0600); err != nil { + return err + } + _, rejected := m.VerifyReleaseV4(verify) + if err := os.WriteFile(p, original, 0600); err != nil { + return err + } + if rejected == nil { + return fmt.Errorf("changed release file accepted: %s", name) + } + } + extra := filepath.Join(out, "unreviewed.txt") + if err := os.WriteFile(extra, []byte("not reviewed"), 0600); err != nil { + return err + } + _, rejected := m.VerifyReleaseV4(verify) + if err := os.Remove(extra); err != nil { + return err + } + if rejected == nil { + return fmt.Errorf("unlisted release file accepted") + } + link := filepath.Join(filepath.Dir(out), "linked-release-public-key.hex") + if err := os.Link(filepath.Join(out, keybundle.ManifestPublicKeyFile), link); err != nil { + return err + } + _, rejected = m.VerifyReleaseV4(verify) + _, retryRejected := m.SignReleaseV4(options) + if err := os.Remove(link); err != nil { + return err + } + if rejected == nil || !strings.Contains(rejected.Error(), "hard link") { + return fmt.Errorf("hardlinked package file accepted: %v", rejected) + } + if retryRejected == nil || !strings.Contains(retryRejected.Error(), "committed publication") || !strings.Contains(retryRejected.Error(), "hard link") { + return fmt.Errorf("unsafe exact retry accepted: %v", retryRejected) + } + if _, err := os.Stat(out); err != nil { + return fmt.Errorf("committed recovery output was not retained: %w", err) + } + // Give an incorrect embedded review a valid manifest signature. Rejection + // must come from recomputing the review, not an incidental bad signature. + transcriptPath := filepath.Join(out, m.FinalTranscriptFile) + originalTranscript, err := os.ReadFile(transcriptPath) + if err != nil { + return err + } + var transcript m.FinalTranscript + if err := m.UnmarshalCanonical(originalTranscript, &transcript); err != nil { + return err + } + for i, ref := range transcript.ReleaseReview.RequiredArtifacts { + if ref.Name == review.ReviewCheckpoint.Signature.Name { + transcript.ReleaseReview.RequiredArtifacts[i].Digest = m.NewDigest([]byte("different committed dependency")) + } + } + transcript, err = m.NewFinalTranscript(transcript) + if err != nil { + return err + } + raw, err := m.MarshalCanonical(transcript) + if err != nil { + return err + } + manifestPath := filepath.Join(out, keybundle.ManifestFile) + originalManifest, err := os.ReadFile(manifestPath) + if err != nil { + return err + } + var manifest artifact.KeyManifest + if err := json.Unmarshal(originalManifest, &manifest); err != nil { + return err + } + manifest.SetupTranscriptHash = m.NewDigest(raw).Blake2b256 + mb, err := json.MarshalIndent(manifest, "", " ") + if err != nil { + return err + } + mb = append(mb, '\n') + private := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x82}, 32)) + sigPath := filepath.Join(out, keybundle.ManifestSignatureFile) + originalSig, err := os.ReadFile(sigPath) + if err != nil { + return err + } + for _, f := range []struct { + p string + b []byte + }{{transcriptPath, raw}, {manifestPath, mb}, {sigPath, []byte(hex.EncodeToString(ed25519.Sign(private, mb)) + "\n")}} { + if err := os.WriteFile(f.p, f.b, 0600); err != nil { + return err + } + } + _, rejected = m.VerifyReleaseV4(verify) + for _, f := range []struct { + p string + b []byte + }{{transcriptPath, originalTranscript}, {manifestPath, originalManifest}, {sigPath, originalSig}} { + if err := os.WriteFile(f.p, f.b, 0600); err != nil { + return err + } + } + if rejected == nil || !strings.Contains(rejected.Error(), "signed release review differs") { + return fmt.Errorf("coherently signed wrong review not rejected: %v", rejected) + } + if _, err := m.VerifyReleaseV4(verify); err != nil { + return err + } + fmt.Println("V4 signed package passed: exact-only source, unchanged key-bundle consumer, signer required, exact retry, wrong files and signed review rejected") + return nil +} diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release_checkpoint.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release_checkpoint.go new file mode 100644 index 00000000..fa49fffb --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release_checkpoint.go @@ -0,0 +1,124 @@ +package main + +import ( + "crypto/ed25519" + "fmt" + "io/fs" + "os" + "path/filepath" + "slices" + "strings" + + "proof-tool/internal/keybundle" + m "proof-tool/internal/mpcceremony" +) + +// This is a terminal fixture branch, separate from the later abort negatives. +func runFinalReleaseCheckpointV4(root, packageDir string, trust m.TrustPaths, d m.CeremonyDefinition, previous m.CheckpointV4, previousRefs m.SignedArtifactRefs, coordinator ed25519.PrivateKey) error { + destination := filepath.Join(root, m.FinalReleasePackagePrefixV4) + if err := filepath.WalkDir(packageDir, func(path string, entry fs.DirEntry, err error) error { + if err != nil { + return err + } + rel, err := filepath.Rel(packageDir, path) + if err != nil { + return err + } + out := filepath.Join(destination, rel) + if entry.IsDir() { + return os.MkdirAll(out, 0700) + } + raw, err := os.ReadFile(path) + if err != nil { + return err + } + return os.WriteFile(out, raw, 0600) + }); err != nil { + return err + } + ref := func(name string) (m.ArtifactRef, error) { + raw, err := os.ReadFile(filepath.Join(root, name)) + return m.ArtifactRef{Name: name, Digest: m.NewDigest(raw)}, err + } + r, err := ref(m.FinalReleasePackagePrefixV4 + keybundle.ManifestFile) + if err != nil { + return err + } + s, err := ref(m.FinalReleasePackagePrefixV4 + keybundle.ManifestSignatureFile) + if err != nil { + return err + } + pair := m.SignedArtifactRefs{Record: r, Signature: s} + evidence := []m.ArtifactRef{} + for _, name := range []string{m.FinalTranscriptFile, m.ReleaseChecksumsFile, keybundle.ManifestPublicKeyFile} { + a, err := ref(m.FinalReleasePackagePrefixV4 + name) + if err != nil { + return err + } + evidence = append(evidence, a) + } + slices.SortFunc(evidence, func(a, b m.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + c := previous + c.Sequence++ + c.PreviousCheckpoint = &previousRefs + c.Transition = m.CheckpointTransitionV4{Kind: m.CheckpointFinalReleaseRecorded, Record: &pair, Evidence: evidence} + c.Progress.FinalRelease = &pair + c.AcceptedArtifacts = append(append(append([]m.ArtifactRef{}, previous.AcceptedArtifacts...), r, s), evidence...) + slices.SortFunc(c.AcceptedArtifacts, func(a, b m.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + prepare := m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: c} + if _, err := m.PrepareCheckpointV4(prepare); err != nil { + return fmt.Errorf("prepare final release checkpoint: %w", err) + } + raw, sig, err := m.SignRecord(c, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + name := fmt.Sprintf("checkpoints/%04d-release", c.Sequence) + if err := os.WriteFile(filepath.Join(root, name+".json"), raw, 0600); err != nil { + return err + } + if err := os.WriteFile(filepath.Join(root, name+".sig"), sig, 0600); err != nil { + return err + } + head := m.SignedArtifactRefs{Record: m.ArtifactRef{Name: name + ".json", Digest: m.NewDigest(raw)}, Signature: m.ArtifactRef{Name: name + ".sig", Digest: m.NewDigest(sig)}} + _, inventory, err := m.VerifyFinalReleaseCheckpointV4(trust, root, head) + if err != nil { + return fmt.Errorf("verify final release checkpoint: %w", err) + } + if len(inventory.Artifacts()) <= 5 { + return fmt.Errorf("release inventory confused bootstrap with whole package") + } + for _, a := range inventory.Artifacts() { + location, err := inventory.Location(a) + if err != nil { + return err + } + data, err := os.ReadFile(filepath.Join(root, location)) + if err != nil || m.NewDigest(data) != a.Digest { + return fmt.Errorf("wrong release inventory location %s: %v", location, err) + } + } + // The exact signed release is still rejected if its committed bytes change. + file := filepath.Join(destination, m.NativeVerifyingKeyFile) + original, err := os.ReadFile(file) + if err != nil { + return err + } + changed := slices.Clone(original) + changed[len(changed)-1] ^= 1 + if err := os.WriteFile(file, changed, 0600); err != nil { + return err + } + _, _, bad := m.VerifyFinalReleaseCheckpointV4(trust, root, head) + if _, err := m.VerifyStoredCheckpointV4(trust, root, head); err != nil { + return fmt.Errorf("structural verification incorrectly depends on package payload: %w", err) + } + if err := os.WriteFile(file, original, 0600); err != nil { + return err + } + if bad == nil { + return fmt.Errorf("changed recorded release accepted") + } + fmt.Println("V4 final release checkpoint passed: private package, exact predecessor, full typed inventory") + return nil +} diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go new file mode 100644 index 00000000..514a99b9 --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -0,0 +1,234 @@ +package main + +import ( + "bytes" + "crypto/ed25519" + "fmt" + "os" + "path/filepath" + "sort" + "strings" + "time" + + m "proof-tool/internal/mpcceremony" +) + +func runCheckpointV4Review(root string, trust m.TrustPaths, d m.CeremonyDefinition, head m.CheckpointV4, headRefs, bundle m.SignedArtifactRefs, coordinator ed25519.PrivateKey) error { + at := mustUTC("2023-08-23T15:11:39Z") + review, err := m.VerifyReleaseReviewV4(trust, root, headRefs, bundle, at) + if err != nil { + return fmt.Errorf("read-only final review: %w", err) + } + again, err := m.VerifyReleaseReviewV4(trust, root, headRefs, bundle, at) + if err != nil { + return err + } + rb, err := m.MarshalCanonical(review) + if err != nil { + return err + } + ab, err := m.MarshalCanonical(again) + if err != nil { + return err + } + if !bytes.Equal(rb, ab) || review.ReviewCheckpoint != headRefs || len(review.Audits) != int(d.AssurancePolicy.PassingCeremonyAudits) { + return fmt.Errorf("final review is not deterministic or exactly bound") + } + // Copy only the declared review dependencies, not the ceremony workspace. + // The real tiny fixture must still verify without its contribution payloads. + snapshot, err := os.MkdirTemp(filepath.Dir(root), "review-dependencies-") + if err != nil { + return err + } + // Command integration tests may retain this public-only verified branch in + // their own temporary workspace. Normal helper runs still remove it. + if os.Getenv("MPC_WORKFLOW_RETAIN_REVIEW") != "1" { + defer os.RemoveAll(snapshot) + } + for _, ref := range review.RequiredArtifacts { + raw, err := os.ReadFile(filepath.Join(root, ref.Name)) + if err != nil { + return err + } + if m.NewDigest(raw) != ref.Digest { + return fmt.Errorf("review dependency has wrong bytes: %s", ref.Name) + } + destination := filepath.Join(snapshot, ref.Name) + if err := os.MkdirAll(filepath.Dir(destination), 0700); err != nil { + return err + } + if err := os.WriteFile(destination, raw, 0600); err != nil { + return err + } + } + if _, err := os.Lstat(filepath.Join(snapshot, d.Phase1Genesis.Name)); !os.IsNotExist(err) { + return fmt.Errorf("review dependency snapshot unexpectedly contains genesis payload: %v", err) + } + snapshotTrust := trust + snapshotTrust.DefinitionPath = filepath.Join(snapshot, head.Definition.Record.Name) + snapshotTrust.DefinitionSignaturePath = filepath.Join(snapshot, head.Definition.Signature.Name) + snapshotReview, err := m.VerifyReleaseReviewV4(snapshotTrust, snapshot, headRefs, bundle, at) + if err != nil { + return fmt.Errorf("exact dependency snapshot review: %w", err) + } + snapshotBytes, err := m.MarshalCanonical(snapshotReview) + if err != nil { + return err + } + if !bytes.Equal(rb, snapshotBytes) { + return fmt.Errorf("dependency-only snapshot changed review") + } + var phase1 m.Chain + chainBytes, err := os.ReadFile(filepath.Join(snapshot, head.Progress.Phase1.Chain.Record.Name)) + if err != nil { + return err + } + if err := m.UnmarshalCanonical(chainBytes, &phase1); err != nil { + return err + } + for _, record := range phase1.Records { + if _, err := os.Lstat(filepath.Join(snapshot, record.OutputPayload.Name)); !os.IsNotExist(err) { + return fmt.Errorf("snapshot unexpectedly contains historical contribution: %v", err) + } + } + var bundleRecord m.OperationalEvidenceBundle + bundleBytes, err := os.ReadFile(filepath.Join(snapshot, bundle.Record.Name)) + if err != nil { + return err + } + if err := m.UnmarshalCanonical(bundleBytes, &bundleRecord); err != nil { + return err + } + for _, ref := range []m.ArtifactRef{headRefs.Signature, head.Definition.Record, head.Definition.Signature, bundle.Signature, phase1.Records[0].Attestation, phase1.Records[0].Erasure, bundleRecord.Phase1.AcceptedHeads[0].AcceptedChainPrefix.Record, bundleRecord.Phase1.RawBeaconResponses[0]} { + file := filepath.Join(snapshot, ref.Name) + original, err := os.ReadFile(file) + if err != nil { + return err + } + if err := os.Remove(file); err != nil { + return err + } + _, rejected := m.VerifyReleaseReviewV4(snapshotTrust, snapshot, headRefs, bundle, at) + if err := os.WriteFile(file, original, 0600); err != nil { + return err + } + if rejected == nil { + return fmt.Errorf("missing dependency accepted in snapshot: %s", ref.Name) + } + } + if err := runCheckpointV4Release(snapshot, snapshotTrust, d, review, filepath.Join(filepath.Dir(root), "identity-keys/release-signer.ed25519.private.hex"), filepath.Join(filepath.Dir(root), "release-v4")); err != nil { + return err + } + if err := runFinalReleaseCheckpointV4(snapshot, filepath.Join(filepath.Dir(root), "release-v4"), snapshotTrust, d, head, headRefs, coordinator); err != nil { + return err + } + renamedTrust := trust + renamedTrust.DefinitionPath = filepath.Join(root, "renamed-trusted-definition.json") + definitionBytes, err := os.ReadFile(trust.DefinitionPath) + if err != nil { + return err + } + if err = os.WriteFile(renamedTrust.DefinitionPath, definitionBytes, 0600); err != nil { + return err + } + renamedReview, err := m.VerifyReleaseReviewV4(renamedTrust, root, headRefs, bundle, at) + if err != nil { + return fmt.Errorf("renamed trusted copy: %w", err) + } + renamedBytes, err := m.MarshalCanonical(renamedReview) + if err != nil { + return err + } + if !bytes.Equal(rb, renamedBytes) { + return fmt.Errorf("trusted local filename changed logical review") + } + for _, badTime := range []time.Time{{}, mustUTC("2023-08-23T15:11:38Z"), at.In(time.FixedZone("other", 3600))} { + bad, err := m.VerifyReleaseReviewV4(trust, root, headRefs, bundle, badTime) + if err == nil || bad.CeremonyID != "" { + return fmt.Errorf("invalid review time accepted: %v", err) + } + } + wrongName := bundle + wrongName.Record.Name = "another/bundle.json" + if result, err := m.VerifyReleaseReviewV4(trust, root, headRefs, wrongName, at); err == nil || result.CeremonyID != "" { + return fmt.Errorf("wrong bundle name accepted") + } + extra := filepath.Join(root, "final/candidate/unreviewed.txt") + if err = os.WriteFile(extra, []byte("unreviewed"), 0600); err != nil { + return err + } + bad, extraErr := m.VerifyReleaseReviewV4(trust, root, headRefs, bundle, at) + if err = os.Remove(extra); err != nil { + return err + } + if extraErr == nil || bad.CeremonyID != "" { + return fmt.Errorf("extra final candidate file accepted") + } + for _, ref := range []m.ArtifactRef{bundle.Signature, review.CandidateArtifacts[0]} { + p := filepath.Join(root, ref.Name) + original, err := os.ReadFile(p) + if err != nil { + return err + } + changed := bytes.Clone(original) + changed[len(changed)-1] ^= 1 + if err = os.WriteFile(p, changed, 0600); err != nil { + return err + } + bad, rejected := m.VerifyReleaseReviewV4(trust, root, headRefs, bundle, at) + if err = os.WriteFile(p, original, 0600); err != nil { + return err + } + if rejected == nil || bad.CeremonyID != "" { + return fmt.Errorf("changed review input accepted: %s", ref.Name) + } + } + write := func(name string, raw []byte) (m.ArtifactRef, error) { + p := filepath.Join(root, name) + if err := os.MkdirAll(filepath.Dir(p), 0700); err != nil { + return m.ArtifactRef{}, err + } + if err := os.WriteFile(p, raw, 0600); err != nil { + return m.ArtifactRef{}, err + } + return m.ArtifactRef{Name: name, Digest: m.NewDigest(raw)}, nil + } + pair := func(name string, record any) (m.SignedArtifactRefs, error) { + raw, sig, err := m.SignRecord(record, d.Coordinator.KeyID, coordinator) + if err != nil { + return m.SignedArtifactRefs{}, err + } + r, err := write(name+".json", raw) + if err != nil { + return m.SignedArtifactRefs{}, err + } + s, err := write(name+".sig", sig) + return m.SignedArtifactRefs{Record: r, Signature: s}, err + } + statement, err := write("review-tests/statement.txt", []byte("Additional public fixture incident after bundle assembly.\n")) + if err != nil { + return err + } + // The factual statement predates assembly but is attached afterwards, so + // chronology alone cannot reject the old bundle: exact bytes must differ. + incident := m.GovernanceRecord{Schema: m.GovernanceRecordSchema, Kind: m.GovernanceIncident, CeremonyID: d.CeremonyID, Phase: m.Phase2, Index: head.Progress.Phase2.AcceptedCount, HeadID: head.Progress.Phase2.HeadRecordID, Evidence: []m.ArtifactRef{statement}, ReasonCode: "review-test", StatementSHA256: statement.Digest.SHA256, SignerID: d.Coordinator.ID, SignerKeyID: d.Coordinator.KeyID, RecordedAt: "2023-08-23T15:11:37.8Z"} + ir, err := pair("review-tests/incident", incident) + if err != nil { + return err + } + updated := head + updated.Sequence++ + updated.PreviousCheckpoint = &headRefs + updated.Transition = m.CheckpointTransitionV4{Kind: m.CheckpointIncidentRecorded, Record: &ir, Evidence: []m.ArtifactRef{statement}} + updated.AcceptedArtifacts = append(append([]m.ArtifactRef{}, head.AcceptedArtifacts...), ir.Record, ir.Signature, statement) + sort.Slice(updated.AcceptedArtifacts, func(i, j int) bool { return updated.AcceptedArtifacts[i].Name < updated.AcceptedArtifacts[j].Name }) + newHead, err := pair("review-tests/checkpoint", updated) + if err != nil { + return err + } + if result, err := m.VerifyReleaseReviewV4(trust, root, newHead, bundle, at); err == nil || !strings.Contains(err.Error(), "cannot add an incident after freezing release review") || result.CeremonyID != "" { + return fmt.Errorf("post-review incident accepted: %v", err) + } + fmt.Println("V4 final review passed: no contribution replay input, deterministic exact binding, changed files and post-review evidence rejected") + return nil +} diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 1cdca52f..8153e971 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -21,6 +21,7 @@ import ( "github.com/consensys/gnark/frontend/cs/r1cs" "golang.org/x/crypto/blake2b" + "proof-tool/internal/circuit/rehearsal" "proof-tool/internal/mpcceremony" "proof-tool/internal/prover" ) @@ -65,11 +66,12 @@ func main() { } func run(outputRoot, operationalEvidenceHelper string) error { + checkpointV4 := os.Getenv("MPC_WORKFLOW_CHECKPOINT_V4") == "1" zeroAssurance := os.Getenv("PROOF_TOOL_TEST_ZERO_ASSURANCE") == "1" checkpointPhase2One := os.Getenv("MPC_WORKFLOW_PHASE2_ONE") == "1" var circuit *mpcceremony.CompiledCircuit var err error - if checkpointPhase2One { + if checkpointPhase2One || checkpointV4 { circuit, err = mpcceremony.CompileForKeyVersion(mpcceremony.KeyVersionRehearsal) } else { compiled, compileErr := frontend.Compile( @@ -102,6 +104,16 @@ func run(outputRoot, operationalEvidenceHelper string) error { } } + // Test-only companion CLI: a distinct architecture variant lets integration + // tests exercise the real command executable without changing this helper's + // own approved identity. All normal allowlist checks still apply. + if binary := os.Getenv("MPC_WORKFLOW_ALLOWED_CLI"); binary != "" { + software, err = mpcceremony.SoftwareBindingWithAllowedBinaryFiles(software, prover.ProofToolVersion, mpcceremony.ModeRehearsal, []string{binary}) + if err != nil { + return fmt.Errorf("bind test companion CLI: %w", err) + } + } + if err := os.Mkdir(outputRoot, 0o700); err != nil { return err } @@ -176,7 +188,7 @@ func run(outputRoot, operationalEvidenceHelper string) error { return err } auditor1KeyPath, auditor2KeyPath := "", "" - if !zeroAssurance { + if !zeroAssurance || os.Getenv("MPC_WORKFLOW_V4_AUDITS") == "1" { auditor1KeyPath, err = writePrivateKey("auditor-01", auditor1Private) if err != nil { return err @@ -214,10 +226,10 @@ func run(outputRoot, operationalEvidenceHelper string) error { ceremonyRoot := filepath.Join(outputRoot, "ceremony") phaseMinimum := uint8(2) phase2Minimum := uint8(2) - if os.Getenv("MPC_WORKFLOW_PHASE1_ONE") == "1" || checkpointPhase2One { + if os.Getenv("MPC_WORKFLOW_PHASE1_ONE") == "1" || checkpointPhase2One || checkpointV4 { phaseMinimum = 1 } - if checkpointPhase2One { + if checkpointPhase2One || checkpointV4 { phase2Minimum = 1 } auditors := []mpcceremony.Identity{} @@ -228,18 +240,31 @@ func run(outputRoot, operationalEvidenceHelper string) error { assurance.MirrorsPerAcceptedHead = 1 assurance.PassingCeremonyAudits = 1 } + releaseVerification := "" + if checkpointV4 { + releaseVerification = mpcceremony.CoordinatorReplayReleaseV1 + if os.Getenv("MPC_WORKFLOW_V4_AUDITS") == "1" { + auditors = []mpcceremony.Identity{auditor1, auditor2} + assurance.PassingCeremonyAudits = 2 + } + if os.Getenv("MPC_WORKFLOW_V4_MIRROR") == "1" { + assurance.MirrorsPerAcceptedHead = 1 + assurance.PublicWitnessesPerPhase = 1 + } + } initialized, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{ RootDir: ceremonyRoot, Circuit: circuit, Definition: mpcceremony.DefinitionOptions{ - Mode: mpcceremony.ModeRehearsal, - CreatedAt: "2023-08-23T15:00:00Z", - SessionNonceHex: "abababababababababababababababababababababababababababababababab", - Software: software, - Coordinator: coordinator, - ReleaseSigner: releaseSigner, - Auditors: auditors, - AssurancePolicy: assurance, + ReleaseVerification: releaseVerification, + Mode: mpcceremony.ModeRehearsal, + CreatedAt: "2023-08-23T15:00:00Z", + SessionNonceHex: "abababababababababababababababababababababababababababababababab", + Software: software, + Coordinator: coordinator, + ReleaseSigner: releaseSigner, + Auditors: auditors, + AssurancePolicy: assurance, Roster: []mpcceremony.Participant{ {Identity: participant1}, {Identity: participant2}, @@ -280,6 +305,9 @@ func run(outputRoot, operationalEvidenceHelper string) error { if err != nil { return err } + if checkpointV4 { + return runCheckpointV4Turn(outputRoot, ceremonyRoot, trust, circuit, trusted.Definition, coordinatorPrivate, coordinatorKeyPath, participant1Private, participant1KeyPath) + } writeHistoricalClose := func( phase mpcceremony.Phase, chain mpcceremony.Chain, @@ -1166,7 +1194,21 @@ func writeTinyPublicEvidence( } scalar := new(big.Int).SetBytes(reversed) scalar.Mod(scalar, ecc.BLS12_381.ScalarField()) - assignment := &tinyCommittedCircuit{Public: scalar, Secret: scalar} + var assignment frontend.Circuit = &tinyCommittedCircuit{Public: scalar, Secret: scalar} + if circuit.Binding.KeyVersion == mpcceremony.KeyVersionRehearsal { + field := ecc.BLS12_381.ScalarField() + q := new(big.Int).Sub(field, big.NewInt(1)) + q.Div(q, big.NewInt(3)) + exponent := new(big.Int).ModInverse(big.NewInt(3), q) + if exponent == nil { + return errors.New("unexpected rehearsal cube subgroup") + } + cubeRoot := new(big.Int).Exp(scalar, exponent, field) + if new(big.Int).Exp(cubeRoot, big.NewInt(3), field).Cmp(scalar) != 0 { + return errors.New("rehearsal golden scalar is not a cube") + } + assignment = &rehearsal.Circuit{X: cubeRoot, Pub: scalar} + } fullWitness, err := frontend.NewWitness(assignment, ecc.BLS12_381.ScalarField()) if err != nil { return err diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 5772484f..4e025354 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -53,6 +53,7 @@ type TrustPaths struct { // externally supplied coordinator trust anchor. type TrustedCeremony struct { Definition CeremonyDefinition + DefinitionRefs SignedArtifactRefs CoordinatorPublicKey ed25519.PublicKey RunningSoftware SoftwareBinding } @@ -276,7 +277,11 @@ func LoadSignedDefinition(paths TrustPaths) (*TrustedCeremony, error) { return nil, errors.New("external coordinator public key does not match the signed coordinator identity") } return &TrustedCeremony{ - Definition: definition, + Definition: definition, + DefinitionRefs: SignedArtifactRefs{ + Record: ArtifactRef{Name: "ceremony.json", Digest: modelDigest(artifactDigestBytes(definitionBytes))}, + Signature: ArtifactRef{Name: "ceremony.sig", Digest: modelDigest(artifactDigestBytes(signatureBytes))}, + }, CoordinatorPublicKey: bytes.Clone(publicKey), }, nil } @@ -782,6 +787,10 @@ type ContributionFilesOptions struct { Environment ContributionEnvironment ContributedAt string CandidateDir string + // ExpectedScope is set by the V4 allocation-aware entry point. It is + // checked after the signed chain is loaded and before contribution + // randomness is sampled. Legacy callers leave it nil. + ExpectedScope *ContributionScope } type ContributionFilesResult struct { @@ -876,6 +885,25 @@ func CreateContributionCandidate(options ContributionFilesOptions) (result Contr if index > len(policy.Participants) || policy.Participants[index-1] != options.ParticipantID { return result, fmt.Errorf("participant %q is not scheduled at contribution index %d", options.ParticipantID, index) } + if options.ExpectedScope != nil { + if index > 255 { + return result, errors.New("contribution index exceeds protocol limit") + } + head, headErr := chain.HeadRecordID() + if headErr != nil { + return result, headErr + } + actual := ContributionScope{ + CeremonyID: trusted.Definition.CeremonyID, + Phase: options.Phase, + Index: uint8(index), + ParticipantID: options.ParticipantID, + ParentHeadID: head, + } + if actual != *options.ExpectedScope { + return result, errors.New("authenticated allocation does not match the exact contribution input snapshot") + } + } if _, statErr := os.Lstat(options.CandidateDir); statErr == nil { return result, fmt.Errorf("fresh candidate directory already exists: %w", fs.ErrExist) } else if !errors.Is(statErr, fs.ErrNotExist) { @@ -1064,6 +1092,10 @@ type AcceptContributionFilesOptions struct { CandidateDir string CoordinatorPrivateKeyPath string AcceptedAt string + // ClassifyCandidateInvalid is set only by the authenticated V4 allocation + // boundary. It exposes stable candidate-content failures to the recovery + // protocol without relabeling trust, predecessor, or filesystem failures. + ClassifyCandidateInvalid bool } type AcceptContributionFilesResult struct { @@ -1083,6 +1115,12 @@ type AcceptContributionFilesResult struct { // immutable evidence, and writes a new signed chain document last. The input // chain is never overwritten. func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result AcceptContributionFilesResult, err error) { + candidateFailure := func(err error) error { + if options.ClassifyCandidateInvalid { + return candidateInvalid(err) + } + return err + } trusted, err := loadOperationalCeremony(options.Trust) if err != nil { return result, err @@ -1210,6 +1248,9 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result Phase1Shape{DomainN: options.Circuit.Binding.DomainSize, ChallengeLength: contributionChallengeSize}, ) if readErr != nil { + if options.ClassifyCandidateInvalid && isCandidateArtifactContent(readErr) { + return result, candidateInvalid(readErr) + } return result, readErr } phase1Candidate = candidate @@ -1241,11 +1282,14 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result previous, verifyCandidate, ); err != nil { - return result, fmt.Errorf("verify candidate Phase 1 transition: %w", err) + return result, candidateFailure(fmt.Errorf("verify candidate Phase 1 transition: %w", err)) } case Phase2: candidate, digest, readErr := ReadPhase2File(candidatePayloadPath, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape)) if readErr != nil { + if options.ClassifyCandidateInvalid && isCandidateArtifactContent(readErr) { + return result, candidateInvalid(readErr) + } return result, readErr } phase2Candidate = candidate @@ -1271,14 +1315,14 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result 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) + return result, candidateFailure(fmt.Errorf("verify candidate Phase 2 transition: %w", err)) } } if modelDigest(candidateDigest) != attestation.OutputPayload.Digest { - return result, errors.New("candidate contribution digest does not match attestation") + return result, candidateFailure(errors.New("candidate contribution digest does not match attestation")) } if err := requireChallengeMatchesDigest(candidateChallenge, previousPayload.Digest); err != nil { - return result, err + return result, candidateFailure(err) } attestationRef := ArtifactRef{Name: names.Attestation, Digest: digestBytes(attestationBytes)} From 9333ad0b995ac90017f3b419c9a9073730336f7b Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 14:14:33 +0900 Subject: [PATCH 64/64] Fix final release discovery inventory (#33) --- .../mpcceremony/checkpoint_v4_commitments.go | 11 +++++++--- .../mpcceremony/checkpoint_v4_discovery.go | 20 ++++++++++++++----- .../checkpoint_v4_enrollment_metadata.go | 2 +- .../mpcceremony/checkpoint_v4_release_test.go | 12 +++++++++++ internal/mpcceremony/checkpoint_v4_test.go | 14 ++++++++++++- 5 files changed, 49 insertions(+), 10 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go index 950da5db..24142af3 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments.go +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -80,11 +80,16 @@ func InspectStoredCheckpointV4(trust TrustPaths, root string, head SignedArtifac return CheckpointV4{}, CheckpointCommitmentsV4{}, err } defer func() { _ = c.reader.root.Close() }() - index, err := checkpointCommitmentsV4(c.ancestry) + index, err := checkpointGuidanceCommitmentsV4(c.reader, c.ancestry) + return c.ancestry.head, index, err +} + +func checkpointGuidanceCommitmentsV4(reader *checkpointReaderV4, ancestry checkpointAncestryV4) (CheckpointCommitmentsV4, error) { + index, err := checkpointCommitmentsV4(ancestry) if err == nil { - index.FinalReleaseArtifacts, err = finalReleaseDownloadArtifactsV4(c.reader, c.ancestry) + index.FinalReleaseArtifacts, err = finalReleaseDownloadArtifactsV4(reader, ancestry) } - return c.ancestry.head, index, err + return index, err } func checkpointCommitmentsV4(a checkpointAncestryV4) (CheckpointCommitmentsV4, error) { diff --git a/internal/mpcceremony/checkpoint_v4_discovery.go b/internal/mpcceremony/checkpoint_v4_discovery.go index 12223985..9e43502f 100644 --- a/internal/mpcceremony/checkpoint_v4_discovery.go +++ b/internal/mpcceremony/checkpoint_v4_discovery.go @@ -15,8 +15,10 @@ type CheckpointDiscoveryV4 struct { } // DiscoverSignedCheckpointV4 authenticates one exact pair, without loading its -// predecessors. Dependencies are precisely the extra files read for this edge -// by the stored ancestry verifier (governance only), not all accepted artifacts. +// predecessors. Dependencies are precisely the extra files needed before the +// stored guidance projection can be derived: governance evidence, or the small +// signed final-release bootstrap that names the closed download inventory. They +// are not a cumulative payload inventory. func DiscoverSignedCheckpointV4(d CeremonyDefinition, definition, definitionSignature, record, signature []byte) (CheckpointDiscoveryV4, error) { c, err := VerifySignedCheckpointV4(d, definition, definitionSignature, record, signature) if err != nil { @@ -30,9 +32,6 @@ func DiscoverSignedCheckpointV4(d CeremonyDefinition, definition, definitionSign } r.Enrollment = &pair } - if !isGovernanceTransitionV4(c.Transition.Kind) { - return r, nil - } add := func(ref ArtifactRef, limit int64) error { if ref.Digest.Size <= 0 || ref.Digest.Size > limit { return errors.New("checkpoint discovery dependency exceeds verification limit") @@ -40,6 +39,17 @@ func DiscoverSignedCheckpointV4(d CeremonyDefinition, definition, definitionSign r.VerificationDependencies = append(r.VerificationDependencies, ref) return nil } + if c.Transition.Kind == CheckpointFinalReleaseRecorded { + for _, ref := range c.Transition.Evidence { + if err := add(ref, 1<<20); err != nil { + return CheckpointDiscoveryV4{}, err + } + } + return r, nil + } + if !isGovernanceTransitionV4(c.Transition.Kind) { + return r, nil + } if err := add(c.Transition.Record.Record, maxSignedRecordBytes); err != nil { return CheckpointDiscoveryV4{}, err } diff --git a/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go b/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go index c485b70a..b48bc9a1 100644 --- a/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go +++ b/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go @@ -29,7 +29,7 @@ func InspectCheckpointGuidanceV4(trust TrustPaths, artifactRoot string, head Sig return CheckpointV4{}, CheckpointCommitmentsV4{}, EnrollmentMetadataV4{}, err } defer func() { _ = c.reader.root.Close() }() - index, err := checkpointCommitmentsV4(c.ancestry) + index, err := checkpointGuidanceCommitmentsV4(c.reader, c.ancestry) if err != nil { return CheckpointV4{}, CheckpointCommitmentsV4{}, EnrollmentMetadataV4{}, err } diff --git a/internal/mpcceremony/checkpoint_v4_release_test.go b/internal/mpcceremony/checkpoint_v4_release_test.go index 72616c0d..64cfd0ca 100644 --- a/internal/mpcceremony/checkpoint_v4_release_test.go +++ b/internal/mpcceremony/checkpoint_v4_release_test.go @@ -43,6 +43,18 @@ func TestFinalReleaseV4DerivesClosedDownloadInventory(t *testing.T) { } } +func TestFinalReleaseV4GuidanceIncludesClosedDownloadInventory(t *testing.T) { + reader, head := finalReleaseDownloadFixtureV4(t) + defer func() { _ = reader.root.Close() }() + index, err := checkpointGuidanceCommitmentsV4(reader, checkpointAncestryV4{head: head}) + if err != nil { + t.Fatal(err) + } + if len(index.FinalReleaseArtifacts) != 9 { + t.Fatalf("final release guidance inventory has %d artifacts, want 9", len(index.FinalReleaseArtifacts)) + } +} + // finalReleaseDownloadFixtureV4 models the real layout: Progress.ReleaseReview // is the signed operational-bundle pair, while the ReleaseReviewV4 lives inside // the signed final/release/setup-transcript.json. Keeping those two record diff --git a/internal/mpcceremony/checkpoint_v4_test.go b/internal/mpcceremony/checkpoint_v4_test.go index 314458ca..9e15ea99 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -5,6 +5,7 @@ import ( "crypto/ed25519" "encoding/json" "fmt" + "reflect" "strings" "testing" @@ -121,7 +122,7 @@ func checkpointTurnV4(t *testing.T, d CeremonyDefinition, start CheckpointV4, ph } func TestCheckpointV4FullStructuralLifecycle(t *testing.T) { - d, c, _, _ := checkpointFixtureV4(t) + d, c, definition, definitionSignature := checkpointFixtureV4(t) turn := checkpointTurnV4(t, d, c, Phase1) c = turn[len(turn)-1] stages := []CheckpointTransitionKind{CheckpointPhase1Closed, CheckpointPhase1BeaconRecorded, CheckpointPhase1Sealed, CheckpointPhase2Initialized, CheckpointPhase2Closed, CheckpointPhase2BeaconRecorded, CheckpointFinalCandidateRecorded, CheckpointReleaseReviewRecorded, CheckpointFinalReleaseRecorded} @@ -195,6 +196,17 @@ func TestCheckpointV4FullStructuralLifecycle(t *testing.T) { if c.Progress.FinalRelease == nil { t.Fatal("did not reach final release") } + raw, signature, err := SignRecord(c, d.Coordinator.KeyID, adversarialPrivateKey(1)) + if err != nil { + t.Fatal(err) + } + discovery, err := DiscoverSignedCheckpointV4(d, definition, definitionSignature, raw, signature) + if err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(discovery.VerificationDependencies, c.Transition.Evidence) { + t.Fatalf("final-release discovery dependencies = %+v, want signed bootstrap evidence %+v", discovery.VerificationDependencies, c.Transition.Evidence) + } for _, kind := range []CheckpointTransitionKind{CheckpointIncidentRecorded, CheckpointAborted, CheckpointRestarted} { pair := checkpointSigned("governance/after-release") tx := CheckpointTransitionV4{Kind: kind, Record: &pair, Evidence: checkpointArtifacts(checkpointArtifact("governance/statement.txt", "public"))}