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)}