From 3996ce247e8652a10a64d34e9c0067ec1fe753ff Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:46:55 +0900 Subject: [PATCH 01/53] feat: author and accept storage submissions --- cmd/mpc-ceremony/checkpoint_command.go | 50 ++- cmd/mpc-ceremony/executor.go | 4 + cmd/mpc-ceremony/parse.go | 7 + cmd/mpc-ceremony/submission_command.go | 386 +++++++++++++++++++ cmd/mpc-ceremony/submission_command_test.go | 225 +++++++++++ cmd/mpc-ceremony/submission_rename_darwin.go | 12 + cmd/mpc-ceremony/submission_rename_linux.go | 12 + cmd/mpc-ceremony/submission_rename_other.go | 12 + cmd/mpc-ceremony/types.go | 26 ++ cmd/mpc-ceremony/usage.go | 29 ++ 10 files changed, 761 insertions(+), 2 deletions(-) create mode 100644 cmd/mpc-ceremony/submission_command.go create mode 100644 cmd/mpc-ceremony/submission_command_test.go create mode 100644 cmd/mpc-ceremony/submission_rename_darwin.go create mode 100644 cmd/mpc-ceremony/submission_rename_linux.go create mode 100644 cmd/mpc-ceremony/submission_rename_other.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 1bc91184..ef79ebfd 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"}} @@ -1460,7 +1469,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) } @@ -1558,7 +1573,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) } @@ -1800,6 +1821,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/executor.go b/cmd/mpc-ceremony/executor.go index 29a68e79..efcaf97f 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -115,6 +115,10 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeInspectSubmission(invocation.Options.(InspectSubmissionOptions)) case CommandInspectSubmissionAcknowledgement: return executeInspectSubmissionAcknowledgement(invocation.Options.(InspectSubmissionAcknowledgementOptions)) + case CommandSubmissionSign: + return executeSubmissionSign(invocation.Options.(SubmissionSignOptions)) + case CommandSubmissionAccept: + return executeSubmissionAccept(invocation.Options.(SubmissionAcceptOptions)) case CommandCheckpointPrepare: return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) case CommandCheckpointSign: diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index c2feac32..a9b1adf8 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -101,6 +101,13 @@ func parseInvocation(args []string) (Invocation, error) { return parseDecision(invocation, rest[1:]) case "checkpoint": return parseCheckpoint(invocation, rest[1:]) + case "submission": + parsed, err := parseSubmission(invocation, rest[1:]) + topic := []string{"submission"} + if len(rest) > 1 { + topic = append(topic, rest[1]) + } + return parsed, wrapCommandError(err, topic...) default: return Invocation{}, &usageError{ message: fmt.Sprintf("unknown command %q", rest[0]), diff --git a/cmd/mpc-ceremony/submission_command.go b/cmd/mpc-ceremony/submission_command.go new file mode 100644 index 00000000..4a15f2ad --- /dev/null +++ b/cmd/mpc-ceremony/submission_command.go @@ -0,0 +1,386 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "slices" + "strings" + + "golang.org/x/crypto/blake2b" + "proof-tool/internal/keybundle" + "proof-tool/internal/mpcceremony" +) + +func parseSubmission(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing submission command", topic: []string{"submission"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"submission"}, args[1:]...)} + } + if args[0] == "accept" { + var options SubmissionAcceptOptions + fs := commandFlagSet("submission accept") + addCheckpointEvidenceFlags(fs, &options.CheckpointEvidenceOptions) + fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh atomic acceptance output directory") + if err := parseFlags(fs, args[1:]); err != nil { + return invocation, err + } + if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" { + return invocation, errors.New("submission accept creates the acknowledgement; do not supply acknowledgement flags") + } + validated := options.CheckpointEvidenceOptions + validated.AcknowledgementPath, validated.AcknowledgementSignaturePath = "/pending/acknowledgement.json", "/pending/acknowledgement.sig" + if err := validateCheckpointEvidenceOptions(validated); err != nil { + return invocation, err + } + kind := mpcceremony.CheckpointTransitionKind(options.TransitionKind) + if kind != mpcceremony.CheckpointPhase1ReceiptAccepted && kind != mpcceremony.CheckpointPhase1CandidateAccepted && kind != mpcceremony.CheckpointPhase2ReceiptAccepted && kind != mpcceremony.CheckpointPhase2CandidateAccepted { + return invocation, errors.New("submission accept supports only receipt-accepted and candidate-accepted transitions") + } + if err := requireValues(pathValue("--coordinator-signing-key", options.CoordinatorSigningKey), pathValue("--out-dir", options.OutDir)); err != nil { + return invocation, err + } + invocation.Command, invocation.Options = CommandSubmissionAccept, options + return invocation, nil + } + if args[0] != "sign" { + return Invocation{}, &usageError{message: fmt.Sprintf("unknown submission command %q", args[0]), topic: []string{"submission"}} + } + var options SubmissionSignOptions + fs := commandFlagSet("submission sign") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.ArtifactRoot, "artifact-root", "", "root containing the complete fetched checkpoint ancestry") + fs.StringVar(&options.CheckpointPath, "checkpoint", "", "exact allocation checkpoint") + fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "allocation checkpoint signature") + fs.StringVar(&options.AttemptID, "attempt-id", "", "globally unique preallocated attempt ID") + fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "assigned participant private key") + fs.StringVar(&options.ReceiptPath, "receipt", "", "exact signed receipt record for a receipt slot") + fs.StringVar(&options.ReceiptSignaturePath, "receipt-signature", "", "detached receipt signature") + fs.StringVar(&options.CandidateDir, "candidate-dir", "", "completed candidate directory for a candidate slot") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh atomic envelope output directory") + if err := parseFlags(fs, args[1:]); err != nil { + return invocation, err + } + if err := requireValues( + pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), pathValue("--artifact-root", options.ArtifactRoot), + pathValue("--checkpoint", options.CheckpointPath), pathValue("--checkpoint-signature", options.CheckpointSignaturePath), + value("--attempt-id", options.AttemptID), pathValue("--participant-signing-key", options.ParticipantSigningKey), pathValue("--out-dir", options.OutDir), + ); err != nil { + return invocation, err + } + receipt := options.ReceiptPath != "" || options.ReceiptSignaturePath != "" + candidate := options.CandidateDir != "" + if receipt == candidate { + return invocation, errors.New("supply exactly one payload form: --receipt with --receipt-signature, or --candidate-dir") + } + if receipt { + if err := requireValues(pathValue("--receipt", options.ReceiptPath), pathValue("--receipt-signature", options.ReceiptSignaturePath)); err != nil { + return invocation, err + } + } + invocation.Command, invocation.Options = CommandSubmissionSign, options + return invocation, nil +} + +func executeSubmissionSign(options SubmissionSignOptions) (CommandResult, error) { + checkpoint, checkpointBytes, err := verifyStoredCheckpointAncestry(CheckpointVerifyStoredOptions{ + InspectCheckpointOptions: InspectCheckpointOptions{InspectDefinitionOptions: InspectDefinitionOptions{CeremonyPath: options.CeremonyPath, CeremonySignaturePath: options.CeremonySignaturePath, CoordinatorPublicKeyFile: options.CoordinatorPublicKeyFile}, CheckpointPath: options.CheckpointPath, CheckpointSignaturePath: options.CheckpointSignaturePath}, + ArtifactRoot: options.ArtifactRoot, + }, options.CheckpointPath, options.CheckpointSignaturePath, make(map[string]struct{}), 0) + if err != nil { + return CommandResult{}, fmt.Errorf("allocation checkpoint ancestry: %w", err) + } + slot, err := allocatedSlotByAttempt(checkpoint, options.AttemptID) + if err != nil { + return CommandResult{}, err + } + trusted, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(InspectDefinitionOptions{CeremonyPath: options.CeremonyPath, CeremonySignaturePath: options.CeremonySignaturePath, CoordinatorPublicKeyFile: options.CoordinatorPublicKeyFile}) + if err != nil { + return CommandResult{}, err + } + payloads, err := submissionPayloadRefs(options, slot) + if err != nil { + return CommandResult{}, err + } + envelope := mpcceremony.SubmissionEnvelopeV1{ + Schema: mpcceremony.SubmissionEnvelopeSchemaV1, Workflow: checkpoint.Workflow, + CeremonyID: checkpoint.CeremonyID, + Definition: mpcceremony.SignedArtifactRefs{ + Record: mpcceremony.ArtifactRef{Name: checkpoint.Definition.Record.Name, Digest: mpcceremony.NewDigest(definitionBytes)}, + Signature: mpcceremony.ArtifactRef{Name: checkpoint.Definition.Signature.Name, Digest: mpcceremony.NewDigest(definitionSignatureBytes)}, + }, + RelayReleaseID: checkpoint.RelayReleaseID, + SubmitterID: slot.IdentityID, SubmitterKeyID: participantKeyID(trusted.Definition, slot.IdentityID), SubmitterRole: mpcceremony.SubmissionRoleParticipant, + Kind: slot.Kind, Phase: slot.Phase, Index: slot.Index, ParentCheckpointSHA256: slot.BasisCheckpointSHA256, + AllocationCheckpointSHA256: mpcceremony.NewDigest(checkpointBytes).SHA256, ParentHeadID: slot.ParentHeadID, + AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, Payloads: payloads, + } + // Kind-specific semantic verification happens before the private key is loaded. + if slot.Kind == mpcceremony.CheckpointSubmissionReceipt { + if err := verifyReceiptEnvelopePayloads(options.ArtifactRoot, trusted, checkpoint, envelope); err != nil { + return CommandResult{}, err + } + } else if err := verifyCandidateSubmissionFiles(options.CandidateDir, trusted.Definition, slot, payloads); err != nil { + return CommandResult{}, err + } + key, _, err := keybundle.LoadExistingPrivateKey(options.ParticipantSigningKey) + if err != nil { + return CommandResult{}, err + } + record, signature, err := mpcceremony.SignSubmissionEnvelope(trusted.Definition, checkpoint, slot, envelope, key) + if err != nil { + return CommandResult{}, err + } + if err := writeAtomicSubmissionDir(options.OutDir, map[string][]byte{"envelope.json": record, "envelope.sig": signature}); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: checkpoint.CeremonyID, Phase: string(slot.Phase), Summary: fmt.Sprintf("signed authenticated %s submission for preallocated attempt", slot.Kind), Outputs: map[string]string{"envelope": filepath.Join(options.OutDir, "envelope.json"), "envelope_signature": filepath.Join(options.OutDir, "envelope.sig")}}, nil +} + +func executeSubmissionAccept(options SubmissionAcceptOptions) (CommandResult, error) { + var acknowledgementBytes, acknowledgementSignature []byte + var coordinatorKey ed25519.PrivateKey + options.AcceptanceSigner = func(trusted *mpcceremony.TrustedCeremony, checkpoint mpcceremony.Checkpoint, slot mpcceremony.CheckpointSubmissionSlot, envelope mpcceremony.SubmissionEnvelopeV1, envelopeRefs mpcceremony.SignedArtifactRefs, manifest mpcceremony.ArtifactRef) ([]byte, []byte, mpcceremony.SignedArtifactRefs, error) { + ack := mpcceremony.SubmissionAcknowledgementV1{ + Schema: mpcceremony.SubmissionAcknowledgementSchemaV1, Workflow: envelope.Workflow, + CeremonyID: envelope.CeremonyID, Definition: envelope.Definition, RelayReleaseID: envelope.RelayReleaseID, + CoordinatorID: trusted.Definition.Coordinator.ID, CoordinatorKeyID: trusted.Definition.Coordinator.KeyID, + SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, + Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, + ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, + ParentHeadID: envelope.ParentHeadID, AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, + Envelope: envelopeRefs, Manifest: manifest, Result: mpcceremony.SubmissionAccepted, + } + key, _, err := keybundle.LoadExistingPrivateKey(options.CoordinatorSigningKey) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + record, signature, err := mpcceremony.SignSubmissionAcknowledgement(trusted.Definition, checkpoint, slot, envelope, envelopeRefs, manifest, ack, key) + if err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + base := "acknowledgements/" + slot.AttemptID + refs := mpcceremony.SignedArtifactRefs{ + Record: mpcceremony.ArtifactRef{Name: base + "/record.json", Digest: mpcceremony.NewDigest(record)}, + Signature: mpcceremony.ArtifactRef{Name: base + "/record.sig", Digest: mpcceremony.NewDigest(signature)}, + } + if err := refs.Validate(); err != nil { + return nil, nil, mpcceremony.SignedArtifactRefs{}, err + } + coordinatorKey, acknowledgementBytes, acknowledgementSignature = key, record, signature + return record, signature, refs, nil + } + built, err := buildCheckpointEvidence(options.CheckpointEvidenceOptions) + if err != nil { + return CommandResult{}, err + } + if len(coordinatorKey) == 0 || len(acknowledgementBytes) == 0 { + return CommandResult{}, errors.New("acceptance evidence did not reach authenticated signing boundary") + } + _, checkpointSignature, err := mpcceremony.SignRecord(built.checkpoint, built.trusted.Definition.Coordinator.KeyID, coordinatorKey) + if err != nil { + return CommandResult{}, err + } + files := map[string][]byte{"acknowledgement.json": acknowledgementBytes, "acknowledgement.sig": acknowledgementSignature, "checkpoint.json": built.canonical, "checkpoint.sig": checkpointSignature} + if err := writeAtomicSubmissionDir(options.OutDir, files); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: built.checkpoint.CeremonyID, Sequence: int(built.checkpoint.Sequence), Summary: fmt.Sprintf("accepted authenticated submission and signed checkpoint %d as one atomic result", built.checkpoint.Sequence), Outputs: map[string]string{ + "acknowledgement": filepath.Join(options.OutDir, "acknowledgement.json"), "acknowledgement_signature": filepath.Join(options.OutDir, "acknowledgement.sig"), + "checkpoint": filepath.Join(options.OutDir, "checkpoint.json"), "checkpoint_signature": filepath.Join(options.OutDir, "checkpoint.sig"), + }}, nil +} + +func allocatedSlotByAttempt(checkpoint mpcceremony.Checkpoint, attemptID string) (mpcceremony.CheckpointSubmissionSlot, error) { + var found *mpcceremony.CheckpointSubmissionSlot + for i := range checkpoint.Submissions { + slot := checkpoint.Submissions[i] + if slot.AttemptID != attemptID { + continue + } + if found != nil { + return mpcceremony.CheckpointSubmissionSlot{}, errors.New("attempt ID is not globally unique in checkpoint") + } + copy := slot + found = © + } + if found == nil || found.Status != mpcceremony.CheckpointSubmissionAllocated { + return mpcceremony.CheckpointSubmissionSlot{}, errors.New("attempt ID does not name an allocated submission slot") + } + return *found, nil +} + +func participantKeyID(definition mpcceremony.CeremonyDefinition, id string) string { + participant, _ := definition.ParticipantByID(id) + return participant.Identity.KeyID +} + +func submissionPayloadRefs(options SubmissionSignOptions, slot mpcceremony.CheckpointSubmissionSlot) ([]mpcceremony.ArtifactRef, error) { + if slot.Kind == mpcceremony.CheckpointSubmissionReceipt { + if options.CandidateDir != "" { + return nil, errors.New("receipt slot requires receipt payloads") + } + base := strings.TrimSuffix(slot.ManifestKey, "/manifest.json") + refs := make([]mpcceremony.ArtifactRef, 0, 2) + for _, item := range []struct{ path, name string }{{options.ReceiptPath, base + "/receipt.json"}, {options.ReceiptSignaturePath, base + "/receipt.sig"}} { + ref, err := submissionFileRef(item.path, item.name) + if err != nil { + return nil, err + } + refs = append(refs, ref) + } + slices.SortFunc(refs, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + return refs, nil + } + if slot.Kind != mpcceremony.CheckpointSubmissionCandidate || options.CandidateDir == "" || options.ReceiptPath != "" || options.ReceiptSignaturePath != "" { + return nil, errors.New("candidate slot requires only --candidate-dir") + } + base := fmt.Sprintf("%s/contributions/%04d", slot.Phase, slot.Index) + files := []struct{ file, name string }{{"contribution.bin", base + "/contribution.bin"}, {"attestation.json", base + "/attestation.json"}, {"attestation.sig", base + "/attestation.sig"}, {"erasure.json", base + "/erasure.json"}, {"erasure.sig", base + "/erasure.sig"}} + refs := make([]mpcceremony.ArtifactRef, 0, len(files)) + for _, item := range files { + ref, err := submissionFileRef(filepath.Join(options.CandidateDir, item.file), item.name) + if err != nil { + return nil, err + } + refs = append(refs, ref) + } + slices.SortFunc(refs, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + return refs, nil +} + +func submissionFileRef(path, name string) (mpcceremony.ArtifactRef, error) { + info, err := os.Lstat(path) + if err != nil { + return mpcceremony.ArtifactRef{}, err + } + if !info.Mode().IsRegular() { + return mpcceremony.ArtifactRef{}, errors.New("submission payload must be a regular file, not a symlink") + } + f, err := os.Open(path) + if err != nil { + return mpcceremony.ArtifactRef{}, err + } + defer f.Close() + opened, err := f.Stat() + if err != nil || !os.SameFile(info, opened) { + return mpcceremony.ArtifactRef{}, errors.New("submission payload changed while being opened") + } + sha := sha256.New() + blake, _ := blake2b.New256(nil) + size, err := io.Copy(io.MultiWriter(sha, blake), f) + if err != nil { + return mpcceremony.ArtifactRef{}, err + } + if size <= 0 || size != info.Size() { + return mpcceremony.ArtifactRef{}, errors.New("submission payload is empty or changed while hashing") + } + return mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size}}, nil +} + +func verifyCandidateSubmissionFiles(candidateDir string, definition mpcceremony.CeremonyDefinition, slot mpcceremony.CheckpointSubmissionSlot, refs []mpcceremony.ArtifactRef) error { + participant, ok := definition.ParticipantByID(slot.IdentityID) + if !ok { + return errors.New("candidate submitter is not an assigned participant") + } + publicBytes, err := hex.DecodeString(participant.Identity.Ed25519PublicKeyHex) + if err != nil || len(publicBytes) != ed25519.PublicKeySize { + return errors.New("candidate participant has an invalid public key") + } + read := func(name string, limit int64) ([]byte, error) { + return readRegularOperationalFile(filepath.Join(candidateDir, name), limit) + } + attestationBytes, err := read("attestation.json", maxOperationalRecordBytes) + if err != nil { + return err + } + attestationSignature, err := read("attestation.sig", 4096) + if err != nil { + return err + } + var attestation mpcceremony.ContributionAttestation + if err := mpcceremony.VerifySignedRecord(attestationBytes, attestationSignature, &attestation, participant.Identity.KeyID, ed25519.PublicKey(publicBytes)); err != nil { + return fmt.Errorf("candidate attestation: %w", err) + } + erasureBytes, err := read("erasure.json", maxOperationalRecordBytes) + if err != nil { + return err + } + erasureSignature, err := read("erasure.sig", 4096) + if err != nil { + return err + } + var erasure mpcceremony.ErasureAttestation + if err := mpcceremony.VerifySignedRecord(erasureBytes, erasureSignature, &erasure, participant.Identity.KeyID, ed25519.PublicKey(publicBytes)); err != nil { + return fmt.Errorf("candidate cleanup record: %w", err) + } + if err := mpcceremony.ValidateErasureForContribution(attestation, erasure); err != nil { + return err + } + if attestation.CeremonyID != definition.CeremonyID || attestation.Phase != slot.Phase || attestation.Index != slot.Index || attestation.ParticipantID != slot.IdentityID || attestation.PreviousAcceptanceID != slot.ParentHeadID { + return errors.New("candidate attestation does not match the exact allocated slot") + } + for _, ref := range refs { + if strings.HasSuffix(ref.Name, "/contribution.bin") && ref.Digest != attestation.OutputPayload.Digest { + return errors.New("candidate contribution bytes do not match the signed attestation") + } + } + return nil +} + +func writeAtomicSubmissionDir(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 { + for name, expected := range files { + actual, readErr := readRegularOperationalFile(filepath.Join(outDir, name), maxOperationalRecordBytes) + if readErr != nil || !slices.Equal(actual, expected) { + return errors.New("submission output already exists with conflicting or incomplete contents") + } + } + entries, readErr := os.ReadDir(outDir) + if readErr != nil || len(entries) != len(files) { + return errors.New("submission output already exists with conflicting or incomplete contents") + } + return nil + } else if !os.IsNotExist(err) { + return err + } + tmp, err := os.MkdirTemp(parent, ".submission-*") + 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/submission_command_test.go b/cmd/mpc-ceremony/submission_command_test.go new file mode 100644 index 00000000..ff3055ef --- /dev/null +++ b/cmd/mpc-ceremony/submission_command_test.go @@ -0,0 +1,225 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "proof-tool/internal/mpcceremony" +) + +func TestSubmissionSignAuthenticatesReceiptSlotAndAncestry(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) + cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + var cp1 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { + t.Fatal(err) + } + slot := cp1.Submissions[len(cp1.Submissions)-1] + participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) + receipt, err := mpcceremony.NewTransferReceipt(handoff, handoffBytes, mpcceremony.ReceiptReceiver, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + t.Fatal(err) + } + base := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID) + if err := os.MkdirAll(base, 0o700); err != nil { + t.Fatal(err) + } + receiptPath, signaturePath := filepath.Join(base, "receipt.json"), filepath.Join(base, "receipt.sig") + receiptBytes, signatureBytes, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, receiptPath, receiptBytes, 0o600) + writeDecisionTestFile(t, signaturePath, signatureBytes, 0o600) + keyPath := filepath.Join(fixture.root, "participant.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(participantKey.Seed())+"\n"), 0o600) + out := filepath.Join(fixture.root, "participant-envelope") + args := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, + "--attempt-id", slot.AttemptID, "--participant-signing-key", keyPath, + "--receipt", receiptPath, "--receipt-signature", signaturePath, "--out-dir", out) + result := runCheckpointCommandCLI(t, args) + envelopeBytes := mustReadTestFile(t, result.Outputs["envelope"]) + envelopeSignature := mustReadTestFile(t, result.Outputs["envelope_signature"]) + if _, err := mpcceremony.VerifySignedSubmissionEnvelope(fixture.definition, cp1, slot, envelopeBytes, envelopeSignature); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(filepath.Join(out, "envelope.json")); err != nil { + t.Fatal(err) + } + runCheckpointCommandCLI(t, args) // byte-identical retry is idempotent + writeDecisionTestFile(t, filepath.Join(out, "envelope.sig"), []byte("conflict"), 0o600) + assertCheckpointCommandFails(t, args, "conflicting or incomplete") +} + +func TestSubmissionSignFailurePublishesNothing(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) + cp1Path, cp1SignaturePath, _, _ := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + var cp1 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { + t.Fatal(err) + } + slot := cp1.Submissions[len(cp1.Submissions)-1] + key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) + keyPath := filepath.Join(fixture.root, "participant.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(key.Seed())+"\n"), 0o600) + out := filepath.Join(fixture.root, "must-not-exist") + args := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, + "--attempt-id", slot.AttemptID, "--participant-signing-key", keyPath, + "--receipt", filepath.Join(fixture.root, "missing.json"), "--receipt-signature", filepath.Join(fixture.root, "missing.sig"), "--out-dir", out) + assertCheckpointCommandFails(t, args, "no such file") + if _, err := os.Stat(out); !os.IsNotExist(err) { + t.Fatalf("failed signing published output: %v", err) + } +} + +func TestSubmissionAcceptCreatesAtomicReceiptAcknowledgementAndCheckpoint(t *testing.T) { + fixture := writeCheckpointCLIFixture(t) + cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) + cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + var cp1 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { + t.Fatal(err) + } + slot := cp1.Submissions[len(cp1.Submissions)-1] + participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) + receipt, err := mpcceremony.NewTransferReceipt(handoff, handoffBytes, mpcceremony.ReceiptReceiver, time.Now().UTC().Format(time.RFC3339Nano)) + if err != nil { + t.Fatal(err) + } + base := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID) + if err := os.MkdirAll(base, 0o700); err != nil { + t.Fatal(err) + } + receiptPath, receiptSignaturePath := filepath.Join(base, "receipt.json"), filepath.Join(base, "receipt.sig") + receiptBytes, receiptSignature, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, receiptPath, receiptBytes, 0o600) + writeDecisionTestFile(t, receiptSignaturePath, receiptSignature, 0o600) + participantKeyPath := filepath.Join(fixture.root, "participant.hex") + writeDecisionTestFile(t, participantKeyPath, []byte(hex.EncodeToString(participantKey.Seed())+"\n"), 0o600) + envelopeDir := filepath.Join(base, "signed-envelope") + signArgs := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, "--attempt-id", slot.AttemptID, "--participant-signing-key", participantKeyPath, "--receipt", receiptPath, "--receipt-signature", receiptSignaturePath, "--out-dir", envelopeDir) + runCheckpointCommandCLI(t, signArgs) + manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, manifestPath, []byte(`{"complete":true}`), 0o600) + coordinatorKeyPath := filepath.Join(fixture.root, "coordinator-accept.hex") + writeDecisionTestFile(t, coordinatorKeyPath, []byte(hex.EncodeToString(ed25519.PrivateKey(fixture.coordinatorKey).Seed())+"\n"), 0o600) + nextAttempt := strings.Repeat("b", 32) + out := filepath.Join(fixture.root, "acceptance") + args := append(append([]string{"--format", "json", "submission", "accept"}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", "--transition", string(mpcceremony.CheckpointPhase1ReceiptAccepted), + "--previous-checkpoint", cp1Path, "--previous-checkpoint-signature", cp1SignaturePath, + "--chain", fixture.chainPath, "--chain-signature", fixture.chainSignaturePath, "--head-payload", fixture.headPayloadPath, + "--transition-record", filepath.Join(envelopeDir, "envelope.json"), "--transition-record-signature", filepath.Join(envelopeDir, "envelope.sig"), + "--manifest", manifestPath, "--next-attempt-id", nextAttempt, "--next-manifest-key", "submissions/candidate/"+nextAttempt+"/manifest.json", + "--coordinator-signing-key", coordinatorKeyPath, "--out-dir", out) + result := runCheckpointCommandCLI(t, args) + if result.Sequence != 2 { + t.Fatalf("sequence = %d", result.Sequence) + } + checkpointBytes, signatureBytes := mustReadTestFile(t, result.Outputs["checkpoint"]), mustReadTestFile(t, result.Outputs["checkpoint_signature"]) + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: fixture.trustArgs[1], DefinitionSignaturePath: fixture.trustArgs[3], CoordinatorPublicKeyPath: fixture.trustArgs[5]}) + if err != nil { + t.Fatal(err) + } + definitionBytes, definitionSignature := mustReadTestFile(t, fixture.trustArgs[1]), mustReadTestFile(t, fixture.trustArgs[3]) + checkpoint, err := mpcceremony.VerifySignedCheckpoint(trusted.Definition, definitionBytes, definitionSignature, checkpointBytes, signatureBytes) + if err != nil { + t.Fatal(err) + } + if checkpoint.Transition.Kind != mpcceremony.CheckpointPhase1ReceiptAccepted || checkpoint.Transition.Acknowledgement == nil { + t.Fatalf("checkpoint transition = %#v", checkpoint.Transition) + } + runCheckpointCommandCLI(t, args) // byte-identical retry +} + +func TestSubmissionCandidateSignAndAcceptReplaysMathematics(t *testing.T) { + if runtime.GOOS != "linux" { + t.Skip("full candidate replay requires Linux executable identity") + } + fixture, participantKey := writeWorkflowCheckpointCLIFixture(t) + cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) + cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + cp2Path, cp2SignaturePath := prepareAndSignReceiptCheckpoint(t, fixture, participantKey, cp1Path, cp1SignaturePath, handoff, handoffBytes, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) + var cp2 mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp2Path), &cp2); err != nil { + t.Fatal(err) + } + var slot mpcceremony.CheckpointSubmissionSlot + for _, candidate := range cp2.Submissions { + if candidate.Kind == mpcceremony.CheckpointSubmissionCandidate && candidate.Status == mpcceremony.CheckpointSubmissionAllocated { + slot = candidate + } + } + if slot.AttemptID == "" { + t.Fatal("candidate slot missing") + } + chainPath := filepath.Join(fixture.root, "phase1", "chain-0001.json") + chainSignaturePath := filepath.Join(fixture.root, "phase1", "chain-0001.sig") + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: fixture.trustArgs[1], DefinitionSignaturePath: fixture.trustArgs[3], CoordinatorPublicKeyPath: fixture.trustArgs[5]}) + if err != nil { + t.Fatal(err) + } + chain, _, err := mpcceremony.LoadSignedChainExact(trusted, mpcceremony.PhaseTranscriptPaths{RootDir: fixture.root, ChainPath: chainPath, ChainSignaturePath: chainSignaturePath}) + if err != nil { + t.Fatal(err) + } + accepted := chain.Records[len(chain.Records)-1] + candidateDir := filepath.Join(fixture.root, "candidate-for-submit") + if err := os.Mkdir(candidateDir, 0o700); err != nil { + t.Fatal(err) + } + for _, item := range []struct { + ref mpcceremony.ArtifactRef + name string + }{{accepted.OutputPayload, "contribution.bin"}, {accepted.Attestation, "attestation.json"}, {accepted.AttestationSignature, "attestation.sig"}, {accepted.Erasure, "erasure.json"}, {accepted.ErasureSignature, "erasure.sig"}} { + writeDecisionTestFile(t, filepath.Join(candidateDir, item.name), mustReadTestFile(t, filepath.Join(fixture.root, filepath.FromSlash(item.ref.Name))), 0o600) + } + participantKeyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "participant-01.ed25519.private.hex") + envelopeDir := filepath.Join(fixture.root, "candidate-envelope") + signArgs := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", cp2Path, "--checkpoint-signature", cp2SignaturePath, "--attempt-id", slot.AttemptID, "--participant-signing-key", participantKeyPath, "--candidate-dir", candidateDir, "--out-dir", envelopeDir) + runCheckpointFixtureCommand(t, fixture, signArgs) + manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) + if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, manifestPath, []byte(`{"complete":true}`), 0o600) + coordinatorKeyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "coordinator.ed25519.private.hex") + out := filepath.Join(fixture.root, "candidate-acceptance") + args := append(append([]string{"--format", "json", "submission", "accept"}, fixture.trustArgs...), + "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", "--transition", string(mpcceremony.CheckpointPhase1CandidateAccepted), + "--previous-checkpoint", cp2Path, "--previous-checkpoint-signature", cp2SignaturePath, + "--chain", chainPath, "--chain-signature", chainSignaturePath, "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), + "--transition-record", filepath.Join(envelopeDir, "envelope.json"), "--transition-record-signature", filepath.Join(envelopeDir, "envelope.sig"), + "--manifest", manifestPath, "--coordinator-signing-key", coordinatorKeyPath, "--out-dir", out) + result := runCheckpointFixtureCommand(t, fixture, args) + if result.Sequence != 3 { + t.Fatalf("sequence = %d", result.Sequence) + } + var checkpoint mpcceremony.Checkpoint + if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, result.Outputs["checkpoint"]), &checkpoint); err != nil { + t.Fatal(err) + } + if checkpoint.Transition.Kind != mpcceremony.CheckpointPhase1CandidateAccepted || checkpoint.Phase1.AcceptedCount != 1 { + t.Fatalf("checkpoint = %#v", checkpoint) + } +} 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..3f51117d 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -58,12 +58,35 @@ const ( CommandInspectCheckpointTransition Command = "inspect checkpoint-transition" CommandInspectSubmission Command = "inspect submission" CommandInspectSubmissionAcknowledgement Command = "inspect submission-acknowledgement" + CommandSubmissionSign Command = "submission sign" + CommandSubmissionAccept Command = "submission accept" CommandCheckpointPrepare Command = "checkpoint prepare" CommandCheckpointSign Command = "checkpoint sign" CommandCheckpointVerify Command = "checkpoint verify" CommandCheckpointVerifyStored Command = "checkpoint verify-stored" ) +type SubmissionSignOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + ArtifactRoot string + CheckpointPath string + CheckpointSignaturePath string + AttemptID string + ParticipantSigningKey string + ReceiptPath string + ReceiptSignaturePath string + CandidateDir string + OutDir string +} + +type SubmissionAcceptOptions struct { + CheckpointEvidenceOptions + CoordinatorSigningKey string + OutDir string +} + type GlobalOptions struct { Format string Quiet bool @@ -393,6 +416,9 @@ type CheckpointEvidenceOptions struct { NextManifestKey string CandidateDir string ReleaseDir string + AcknowledgementRecordName string + AcknowledgementSignatureName string + AcceptanceSigner checkpointAcceptanceSigner } type CheckpointPrepareOptions struct { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index b0f117c2..cdcd888c 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -247,6 +247,35 @@ 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. +`, + "submission": `Usage: + mpc-ceremony submission [flags] + +Participant-authored storage-first submission envelopes. The exact slot is +selected only by its coordinator-preallocated attempt ID. +`, + "submission accept": `Usage: + mpc-ceremony submission accept [checkpoint evidence flags except acknowledgement] \ + --coordinator-signing-key KEY --out-dir FRESH_DIR + +Replays the complete stored ancestry and the receipt or candidate evidence, +then creates the accepted acknowledgement and its descendant checkpoint as one +atomic four-file result. The coordinator key is loaded only after all untrusted +evidence passes verification. The acknowledgement is not acceptance by itself; +Relay must publish it only with the signed descendant checkpoint. +`, + "submission sign": `Usage: + mpc-ceremony submission sign --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --artifact-root DIR \ + --checkpoint FILE --checkpoint-signature FILE --attempt-id ID \ + --participant-signing-key KEY \ + (--receipt FILE --receipt-signature FILE | --candidate-dir DIR) \ + --out-dir FRESH_DIR + +Authenticates the complete stored checkpoint ancestry, derives the exact +allocated slot, hashes only its fixed receipt or candidate payload inventory, +and atomically writes the participant-signed envelope pair. It does not create +or upload the transport manifest and never accepts a submission. `, "checkpoint prepare": `Usage: mpc-ceremony checkpoint prepare --ceremony FILE --ceremony-signature FILE \ From 5329176aa2fa9a015f6f73d502573ad96c0960b9 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Tue, 15 Sep 2026 23:55:58 +0900 Subject: [PATCH 02/53] fix: bind submissions to logical ceremony paths --- cmd/mpc-ceremony/checkpoint_command.go | 32 ++++++++ cmd/mpc-ceremony/checkpoint_command_test.go | 13 +-- cmd/mpc-ceremony/submission_command.go | 87 ++++++++++++++++++--- cmd/mpc-ceremony/submission_command_test.go | 38 ++++++--- 4 files changed, 143 insertions(+), 27 deletions(-) diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index ef79ebfd..3b85e416 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -1455,6 +1455,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 @@ -1462,6 +1465,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 } @@ -1554,6 +1560,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") } @@ -1808,6 +1817,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 { diff --git a/cmd/mpc-ceremony/checkpoint_command_test.go b/cmd/mpc-ceremony/checkpoint_command_test.go index 3d90eba0..e6a9a985 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) diff --git a/cmd/mpc-ceremony/submission_command.go b/cmd/mpc-ceremony/submission_command.go index 4a15f2ad..62840d82 100644 --- a/cmd/mpc-ceremony/submission_command.go +++ b/cmd/mpc-ceremony/submission_command.go @@ -132,7 +132,7 @@ func executeSubmissionSign(options SubmissionSignOptions) (CommandResult, error) if err := verifyReceiptEnvelopePayloads(options.ArtifactRoot, trusted, checkpoint, envelope); err != nil { return CommandResult{}, err } - } else if err := verifyCandidateSubmissionFiles(options.CandidateDir, trusted.Definition, slot, payloads); err != nil { + } else if err := verifyCandidateSubmissionFiles(options.CandidateDir, trusted.Definition, checkpoint, slot, payloads); err != nil { return CommandResult{}, err } key, _, err := keybundle.LoadExistingPrivateKey(options.ParticipantSigningKey) @@ -197,7 +197,7 @@ func executeSubmissionAccept(options SubmissionAcceptOptions) (CommandResult, er if err := writeAtomicSubmissionDir(options.OutDir, files); err != nil { return CommandResult{}, err } - return CommandResult{CeremonyID: built.checkpoint.CeremonyID, Sequence: int(built.checkpoint.Sequence), Summary: fmt.Sprintf("accepted authenticated submission and signed checkpoint %d as one atomic result", built.checkpoint.Sequence), Outputs: map[string]string{ + return CommandResult{CeremonyID: built.checkpoint.CeremonyID, Sequence: int(built.checkpoint.Sequence), Summary: fmt.Sprintf("prepared signed acceptance checkpoint %d; not published", built.checkpoint.Sequence), Outputs: map[string]string{ "acknowledgement": filepath.Join(options.OutDir, "acknowledgement.json"), "acknowledgement_signature": filepath.Join(options.OutDir, "acknowledgement.sig"), "checkpoint": filepath.Join(options.OutDir, "checkpoint.json"), "checkpoint_signature": filepath.Join(options.OutDir, "checkpoint.sig"), }}, nil @@ -232,10 +232,13 @@ func submissionPayloadRefs(options SubmissionSignOptions, slot mpcceremony.Check if options.CandidateDir != "" { return nil, errors.New("receipt slot requires receipt payloads") } - base := strings.TrimSuffix(slot.ManifestKey, "/manifest.json") + base := fmt.Sprintf("%s/custody/%04d", slot.Phase, slot.Index) refs := make([]mpcceremony.ArtifactRef, 0, 2) - for _, item := range []struct{ path, name string }{{options.ReceiptPath, base + "/receipt.json"}, {options.ReceiptSignaturePath, base + "/receipt.sig"}} { - ref, err := submissionFileRef(item.path, item.name) + for _, item := range []struct { + path, name string + limit int64 + }{{options.ReceiptPath, base + "/outbound-receipt.json", maxOperationalRecordBytes}, {options.ReceiptSignaturePath, base + "/outbound-receipt.sig", 4096}} { + ref, err := submissionFileRef(item.path, item.name, item.limit, 0) if err != nil { return nil, err } @@ -248,10 +251,30 @@ func submissionPayloadRefs(options SubmissionSignOptions, slot mpcceremony.Check return nil, errors.New("candidate slot requires only --candidate-dir") } base := fmt.Sprintf("%s/contributions/%04d", slot.Phase, slot.Index) - files := []struct{ file, name string }{{"contribution.bin", base + "/contribution.bin"}, {"attestation.json", base + "/attestation.json"}, {"attestation.sig", base + "/attestation.sig"}, {"erasure.json", base + "/erasure.json"}, {"erasure.sig", base + "/erasure.sig"}} + attestationBytes, err := readRegularOperationalFile(filepath.Join(options.CandidateDir, "attestation.json"), maxOperationalRecordBytes) + if err != nil { + return nil, err + } + var claimed mpcceremony.ContributionAttestation + if err := mpcceremony.UnmarshalCanonical(attestationBytes, &claimed); err != nil { + return nil, fmt.Errorf("candidate attestation: %w", err) + } + if claimed.OutputPayload.Digest.Size <= 0 || claimed.OutputPayload.Digest.Size > mpcceremony.MaxArtifactSize { + return nil, errors.New("candidate attestation has an invalid contribution size") + } + files := []struct { + file, name string + limit, exact int64 + }{ + {"attestation.json", base + "/attestation.json", maxOperationalRecordBytes, 0}, + {"attestation.sig", base + "/attestation.sig", 4096, 0}, + {"erasure.json", base + "/erasure.json", maxOperationalRecordBytes, 0}, + {"erasure.sig", base + "/erasure.sig", 4096, 0}, + {"contribution.bin", base + "/contribution.bin", claimed.OutputPayload.Digest.Size, claimed.OutputPayload.Digest.Size}, + } refs := make([]mpcceremony.ArtifactRef, 0, len(files)) for _, item := range files { - ref, err := submissionFileRef(filepath.Join(options.CandidateDir, item.file), item.name) + ref, err := submissionFileRef(filepath.Join(options.CandidateDir, item.file), item.name, item.limit, item.exact) if err != nil { return nil, err } @@ -261,7 +284,7 @@ func submissionPayloadRefs(options SubmissionSignOptions, slot mpcceremony.Check return refs, nil } -func submissionFileRef(path, name string) (mpcceremony.ArtifactRef, error) { +func submissionFileRef(path, name string, maximum, exact int64) (mpcceremony.ArtifactRef, error) { info, err := os.Lstat(path) if err != nil { return mpcceremony.ArtifactRef{}, err @@ -269,6 +292,9 @@ func submissionFileRef(path, name string) (mpcceremony.ArtifactRef, error) { if !info.Mode().IsRegular() { return mpcceremony.ArtifactRef{}, errors.New("submission payload must be a regular file, not a symlink") } + if info.Size() <= 0 || info.Size() > maximum || (exact > 0 && info.Size() != exact) { + return mpcceremony.ArtifactRef{}, errors.New("submission payload size is outside its authenticated bound") + } f, err := os.Open(path) if err != nil { return mpcceremony.ArtifactRef{}, err @@ -284,13 +310,13 @@ func submissionFileRef(path, name string) (mpcceremony.ArtifactRef, error) { if err != nil { return mpcceremony.ArtifactRef{}, err } - if size <= 0 || size != info.Size() { + if size != info.Size() { return mpcceremony.ArtifactRef{}, errors.New("submission payload is empty or changed while hashing") } return mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size}}, nil } -func verifyCandidateSubmissionFiles(candidateDir string, definition mpcceremony.CeremonyDefinition, slot mpcceremony.CheckpointSubmissionSlot, refs []mpcceremony.ArtifactRef) error { +func verifyCandidateSubmissionFiles(candidateDir string, definition mpcceremony.CeremonyDefinition, checkpoint mpcceremony.Checkpoint, slot mpcceremony.CheckpointSubmissionSlot, refs []mpcceremony.ArtifactRef) error { participant, ok := definition.ParticipantByID(slot.IdentityID) if !ok { return errors.New("candidate submitter is not an assigned participant") @@ -329,23 +355,58 @@ func verifyCandidateSubmissionFiles(candidateDir string, definition mpcceremony. if err := mpcceremony.ValidateErasureForContribution(attestation, erasure); err != nil { return err } - if attestation.CeremonyID != definition.CeremonyID || attestation.Phase != slot.Phase || attestation.Index != slot.Index || attestation.ParticipantID != slot.IdentityID || attestation.PreviousAcceptanceID != slot.ParentHeadID { + phaseState := checkpoint.Phase1 + if slot.Phase == mpcceremony.Phase2 { + if checkpoint.Phase2 == nil { + return errors.New("candidate slot has no authenticated Phase 2 state") + } + phaseState = *checkpoint.Phase2 + } + if attestation.CeremonyID != definition.CeremonyID || attestation.Phase != slot.Phase || attestation.Index != slot.Index || attestation.ParticipantID != slot.IdentityID || attestation.PreviousAcceptanceID != slot.ParentHeadID || attestation.PreviousPayload != phaseState.HeadPayload { return errors.New("candidate attestation does not match the exact allocated slot") } + var outputRef mpcceremony.ArtifactRef for _, ref := range refs { - if strings.HasSuffix(ref.Name, "/contribution.bin") && ref.Digest != attestation.OutputPayload.Digest { - return errors.New("candidate contribution bytes do not match the signed attestation") + if strings.HasSuffix(ref.Name, "/contribution.bin") { + outputRef = ref + } + } + if outputRef != attestation.OutputPayload { + return errors.New("candidate contribution name or bytes do not match the signed attestation") + } + wantSmall := map[string]mpcceremony.Digest{ + baseNameForSubmissionRef(refs, "/attestation.json"): mpcceremony.NewDigest(attestationBytes), + baseNameForSubmissionRef(refs, "/attestation.sig"): mpcceremony.NewDigest(attestationSignature), + baseNameForSubmissionRef(refs, "/erasure.json"): mpcceremony.NewDigest(erasureBytes), + baseNameForSubmissionRef(refs, "/erasure.sig"): mpcceremony.NewDigest(erasureSignature), + } + for _, ref := range refs { + if want, ok := wantSmall[ref.Name]; ok && ref.Digest != want { + return errors.New("candidate signed record changed while being validated") } } return nil } +func baseNameForSubmissionRef(refs []mpcceremony.ArtifactRef, suffix string) string { + for _, ref := range refs { + if strings.HasSuffix(ref.Name, suffix) { + return ref.Name + } + } + return "" +} + func writeAtomicSubmissionDir(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("submission 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) { diff --git a/cmd/mpc-ceremony/submission_command_test.go b/cmd/mpc-ceremony/submission_command_test.go index ff3055ef..22aef26a 100644 --- a/cmd/mpc-ceremony/submission_command_test.go +++ b/cmd/mpc-ceremony/submission_command_test.go @@ -31,11 +31,11 @@ func TestSubmissionSignAuthenticatesReceiptSlotAndAncestry(t *testing.T) { if err != nil { t.Fatal(err) } - base := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID) - if err := os.MkdirAll(base, 0o700); err != nil { + receiptDir := filepath.Join(fixture.root, "phase1", "custody", "0001") + if err := os.MkdirAll(receiptDir, 0o700); err != nil { t.Fatal(err) } - receiptPath, signaturePath := filepath.Join(base, "receipt.json"), filepath.Join(base, "receipt.sig") + receiptPath, signaturePath := filepath.Join(receiptDir, "outbound-receipt.json"), filepath.Join(receiptDir, "outbound-receipt.sig") receiptBytes, signatureBytes, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) if err != nil { t.Fatal(err) @@ -44,7 +44,7 @@ func TestSubmissionSignAuthenticatesReceiptSlotAndAncestry(t *testing.T) { writeDecisionTestFile(t, signaturePath, signatureBytes, 0o600) keyPath := filepath.Join(fixture.root, "participant.hex") writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(participantKey.Seed())+"\n"), 0o600) - out := filepath.Join(fixture.root, "participant-envelope") + out := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) args := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, "--attempt-id", slot.AttemptID, "--participant-signing-key", keyPath, @@ -100,11 +100,11 @@ func TestSubmissionAcceptCreatesAtomicReceiptAcknowledgementAndCheckpoint(t *tes if err != nil { t.Fatal(err) } - base := filepath.Join(fixture.root, "submissions", "receipt", slot.AttemptID) - if err := os.MkdirAll(base, 0o700); err != nil { + receiptDir := filepath.Join(fixture.root, "phase1", "custody", "0001") + if err := os.MkdirAll(receiptDir, 0o700); err != nil { t.Fatal(err) } - receiptPath, receiptSignaturePath := filepath.Join(base, "receipt.json"), filepath.Join(base, "receipt.sig") + receiptPath, receiptSignaturePath := filepath.Join(receiptDir, "outbound-receipt.json"), filepath.Join(receiptDir, "outbound-receipt.sig") receiptBytes, receiptSignature, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) if err != nil { t.Fatal(err) @@ -113,7 +113,7 @@ func TestSubmissionAcceptCreatesAtomicReceiptAcknowledgementAndCheckpoint(t *tes writeDecisionTestFile(t, receiptSignaturePath, receiptSignature, 0o600) participantKeyPath := filepath.Join(fixture.root, "participant.hex") writeDecisionTestFile(t, participantKeyPath, []byte(hex.EncodeToString(participantKey.Seed())+"\n"), 0o600) - envelopeDir := filepath.Join(base, "signed-envelope") + envelopeDir := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) signArgs := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, "--attempt-id", slot.AttemptID, "--participant-signing-key", participantKeyPath, "--receipt", receiptPath, "--receipt-signature", receiptSignaturePath, "--out-dir", envelopeDir) runCheckpointCommandCLI(t, signArgs) manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) @@ -149,6 +149,16 @@ func TestSubmissionAcceptCreatesAtomicReceiptAcknowledgementAndCheckpoint(t *tes if checkpoint.Transition.Kind != mpcceremony.CheckpointPhase1ReceiptAccepted || checkpoint.Transition.Acknowledgement == nil { t.Fatalf("checkpoint transition = %#v", checkpoint.Transition) } + ackDir := filepath.Join(fixture.root, "acknowledgements", slot.AttemptID) + if err := os.MkdirAll(ackDir, 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, filepath.Join(ackDir, "record.json"), mustReadTestFile(t, result.Outputs["acknowledgement"]), 0o600) + writeDecisionTestFile(t, filepath.Join(ackDir, "record.sig"), mustReadTestFile(t, result.Outputs["acknowledgement_signature"]), 0o600) + stored := runCheckpointCommandCLI(t, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", result.Outputs["checkpoint"], "--checkpoint-signature", result.Outputs["checkpoint_signature"])) + if stored.CheckpointEvidenceInspection == nil || !stored.CheckpointEvidenceInspection.FullyVerified { + t.Fatalf("stored acceptance = %#v", stored.CheckpointEvidenceInspection) + } runCheckpointCommandCLI(t, args) // byte-identical retry } @@ -195,7 +205,7 @@ func TestSubmissionCandidateSignAndAcceptReplaysMathematics(t *testing.T) { writeDecisionTestFile(t, filepath.Join(candidateDir, item.name), mustReadTestFile(t, filepath.Join(fixture.root, filepath.FromSlash(item.ref.Name))), 0o600) } participantKeyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "participant-01.ed25519.private.hex") - envelopeDir := filepath.Join(fixture.root, "candidate-envelope") + envelopeDir := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) signArgs := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", cp2Path, "--checkpoint-signature", cp2SignaturePath, "--attempt-id", slot.AttemptID, "--participant-signing-key", participantKeyPath, "--candidate-dir", candidateDir, "--out-dir", envelopeDir) runCheckpointFixtureCommand(t, fixture, signArgs) manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) @@ -222,4 +232,14 @@ func TestSubmissionCandidateSignAndAcceptReplaysMathematics(t *testing.T) { if checkpoint.Transition.Kind != mpcceremony.CheckpointPhase1CandidateAccepted || checkpoint.Phase1.AcceptedCount != 1 { t.Fatalf("checkpoint = %#v", checkpoint) } + ackDir := filepath.Join(fixture.root, "acknowledgements", slot.AttemptID) + if err := os.MkdirAll(ackDir, 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, filepath.Join(ackDir, "record.json"), mustReadTestFile(t, result.Outputs["acknowledgement"]), 0o600) + writeDecisionTestFile(t, filepath.Join(ackDir, "record.sig"), mustReadTestFile(t, result.Outputs["acknowledgement_signature"]), 0o600) + stored := runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", result.Outputs["checkpoint"], "--checkpoint-signature", result.Outputs["checkpoint_signature"])) + if stored.CheckpointEvidenceInspection == nil || !stored.CheckpointEvidenceInspection.FullyVerified { + t.Fatalf("stored candidate acceptance = %#v", stored.CheckpointEvidenceInspection) + } } From 89da1e4106e71d4406b875849d06e473cbe7e3b6 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:08:54 +0900 Subject: [PATCH 03/53] Preserve released ceremony verification before protocol revision --- cmd/mpc-ceremony/checkpoint_command.go | 2 +- cmd/mpc-ceremony/checkpoint_command_test.go | 6 ++- cmd/mpc-ceremony/journey_inspection.go | 2 +- docs/ceremony-schema-compatibility.md | 45 +++++++++++++++++++ internal/mpcceremony/audit.go | 30 +++++++++---- internal/mpcceremony/chain.go | 2 +- internal/mpcceremony/checkpoint.go | 2 +- internal/mpcceremony/decision.go | 4 +- internal/mpcceremony/definition.go | 8 ++-- internal/mpcceremony/model.go | 3 +- internal/mpcceremony/operational.go | 4 +- internal/mpcceremony/operational_builder.go | 2 +- internal/mpcceremony/operational_bundle.go | 6 +-- internal/mpcceremony/operational_prepare.go | 4 +- .../mpcceremony/release_compatibility_test.go | 30 +++++++++++++ 15 files changed, 122 insertions(+), 28 deletions(-) create mode 100644 docs/ceremony-schema-compatibility.md create mode 100644 internal/mpcceremony/release_compatibility_test.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 3b85e416..83e5e20d 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -1381,7 +1381,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 diff --git a/cmd/mpc-ceremony/checkpoint_command_test.go b/cmd/mpc-ceremony/checkpoint_command_test.go index e6a9a985..071dcf92 100644 --- a/cmd/mpc-ceremony/checkpoint_command_test.go +++ b/cmd/mpc-ceremony/checkpoint_command_test.go @@ -1074,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/journey_inspection.go b/cmd/mpc-ceremony/journey_inspection.go index cff4620a..4b277a4a 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.Schema == mpcceremony.DefinitionSchemaV3 && d.AssurancePolicy != nil { r.MinimumPublicWitnesses = int(d.AssurancePolicy.PublicWitnessesPerPhase) r.MinimumMirrorsPerAcceptedHead = int(d.AssurancePolicy.MirrorsPerAcceptedHead) r.MinimumPassingCeremonyAudits = int(d.AssurancePolicy.PassingCeremonyAudits) diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md new file mode 100644 index 00000000..6caea5d9 --- /dev/null +++ b/docs/ceremony-schema-compatibility.md @@ -0,0 +1,45 @@ +# Ceremony compatibility baseline + +Verified against released commit `47bec5663d04a4f8ac330fc38f126e6e7c1140f1` +(PR #31, September 15, 2026). These meanings are frozen. The trusted-coordinator +revision is not implemented by this inventory. + +| Boundary | Released versions | Preserve | +| --- | --- | --- | +| Ceremony definition | `proof-tool-mpc-ceremony-definition-v1/v2/v3` | V1 single binary; V2 allowlist; V1/V2 implicit assurance minima; V3 explicit optional assurance policy | +| Checkpoint | `proof-tool-mpc-checkpoint-v1/v2/v3` | V1 legacy definition; V2 optional assurance and early Phase 1; V3 both phases through final release | +| Checkpoint workflow | `storage-first-v1` | Allocated attempts and manifest keys; signed participant envelopes and coordinator acknowledgements | +| Submission | `proof-tool-mpc-submission-envelope-v1`, `proof-tool-mpc-submission-acknowledgement-v1` | Exact identity, phase, turn, parent, attempt, manifest and payload binding | +| Operational bundle | `proof-tool-mpc-operational-evidence-bundle-v2/v3` | V2 legacy minima; V3 exact assurance projection and explicit empty disabled collections | +| Component records | Enrollment/handoff/receipt/witness/beacon-evidence/mirror/governance V1; contribution/erasure V2 | Existing signed claims, identities and exact file bindings | +| Final candidate | `proof-tool-mpc-release-candidate-v2` | Coordinator replay and exact final file inventory | +| Final transcript | `proof-tool-mpc-final-transcript-v1/v2` | V1 requires audit; V2 explicit assurance policy and audit list | +| Release manifest | `proof-tool-key-manifest-v1` | Existing application bundle format; Definition V3 ceremony signing additionally requires signer replay | +| Signed release ID | `proof-tool/mpc-ceremony/signed-release/v1` | Exact existing signed-release binding | +| Production decision and draft | `proof-tool-mpc-production-decision-v1/v2`, corresponding `-draft-v1/v2` | V1 audit requirements; V2 exact optional-assurance gates | +| Decision signature | `proof-tool-mpc-production-decision-signature-v1` | Existing signed decision identity and bytes | + +Coordinator `PrepareFinalization` and `Finalize` already required `replayAll` +in `c1f177ee486fd555fac0dc4d9812b86737fccdfd` (July 31). PR #31 added the +additional signer replay requirement for Definition V3. Do not remove that +check by changing the meaning of V3 or a generic "current schema" constant. + +## New-version implementation gate + +- Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed + trust and submission rules; do not emit them until the whole verifier path + exists and has negative tests. +- Give the changed ceremony release claim an explicit new version and trust + meaning. Do not silently change ordinary application key-bundle verification. +- Version final transcript and production decisions where they consume that + changed claim. Reuse component evidence/candidate formats only where their + exact signed meaning stays unchanged. +- Keep old signing and verification dispatch intact. Unknown versions fail + closed; missing fields do not select the simplified path. +- Coordinator replay stays mandatory. Release signer stays required; only its + duplicate mathematical replay becomes optional in the new path. + +The runtime has no dependency on a downstream delivery application's version. +Provider keys, buckets and upload manifests belong outside proof-tool. Existing +released coupling remains legacy behavior; new protocol outputs use logical +artifact names and hashes only. diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 92758829..8f32d4d4 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -399,6 +399,25 @@ func compareCandidateToReplay( 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 +450,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( @@ -1074,7 +1088,7 @@ func verifyPassingAudits( inputs []AuditArtifact, ) ([]ArtifactRef, time.Time, error) { minimum := 1 - if definition.Schema == DefinitionSchema { + if definition.Schema == DefinitionSchemaV3 { minimum = int(definition.AssurancePolicy.PassingCeremonyAudits) } if len(inputs) < minimum { diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index 06d77c87..ad0288b0 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -608,7 +608,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.Schema != DefinitionSchemaV3 || (definition.AssurancePolicy != nil && definition.AssurancePolicy.PublicWitnessesPerPhase > 0) if definition.Mode == ModeProduction && witnessesEnabled { lead += time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second diff --git a/internal/mpcceremony/checkpoint.go b/internal/mpcceremony/checkpoint.go index e4613dc2..b24aec6f 100644 --- a/internal/mpcceremony/checkpoint.go +++ b/internal/mpcceremony/checkpoint.go @@ -687,7 +687,7 @@ func VerifySignedCheckpoint(definition CeremonyDefinition, definitionBytes, defi } func validateCheckpointDefinitionVersion(definition CeremonyDefinition, checkpoint Checkpoint) error { - if definition.Schema == DefinitionSchema { + 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/decision.go b/internal/mpcceremony/decision.go index 235c9f03..befd6566 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -859,7 +859,7 @@ func validateProductionDecisionBinding(definition CeremonyDefinition, decision P if decision.CeremonyID != definition.CeremonyID { return errors.New("production decision ceremony_id does not match the signed definition") } - if definition.Schema == DefinitionSchema { + if 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 +902,7 @@ func validateProductionDecisionBinding(definition CeremonyDefinition, decision P } func expectedFinalTranscriptSchema(definition CeremonyDefinition) string { - if definition.Schema == DefinitionSchema { + if definition.Schema == DefinitionSchemaV3 { return FinalTranscriptSchema } return FinalTranscriptSchemaV1 diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index f4b40d35..d7311af1 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -216,7 +216,7 @@ func (d CeremonyDefinition) Validate() error { func (d CeremonyDefinition) validate(requireID bool) error { switch d.Schema { - case DefinitionSchema: + case DefinitionSchemaV3: case DefinitionSchemaV2: if d.AssurancePolicy != nil { return errors.New("definition v2 must not contain v3-only assurance_policy") @@ -291,7 +291,7 @@ 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 { + if (d.Schema == DefinitionSchemaV3 || d.Schema == DefinitionSchemaV2) && len(d.Software.Binaries) == 0 { return errors.New("definition v2 or v3 requires at least one allowed software binary") } if d.Mode == ModeProduction { @@ -320,7 +320,7 @@ 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 { + if d.Schema == DefinitionSchemaV3 && d.Auditors == nil { return errors.New("definition v3 requires an explicit auditors array; use [] when audits are disabled") } if len(d.Auditors) > MaxAuditors { @@ -358,7 +358,7 @@ func (d CeremonyDefinition) validate(requireID bool) error { keyIDs[auditor.KeyID] = "auditor" publicKeyFingerprints[auditor.PublicKeyFingerprint] = "auditor" } - if d.Schema == DefinitionSchema { + if d.Schema == DefinitionSchemaV3 { if d.AssurancePolicy == nil { return errors.New("definition v3 requires assurance_policy; omission does not disable controls") } diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index 97561b38..f175e6a9 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -21,7 +21,8 @@ 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" + DefinitionSchema = DefinitionSchemaV3 DetachedSignatureSchema = "proof-tool-mpc-detached-signature-v1" ContributionAttestationSchema = "proof-tool-mpc-contribution-attestation-v2" ErasureAttestationSchema = "proof-tool-mpc-erasure-attestation-v2" diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index 25e320dd..2907c611 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.Schema == DefinitionSchemaV3 && 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.Schema == DefinitionSchemaV3 && 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..70b1c3b3 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.Schema == DefinitionSchemaV3 && 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..9dd0448d 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -328,7 +328,7 @@ 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 options.Definition.Schema == DefinitionSchemaV3 { if bundle.Schema != OperationalEvidenceBundleSchema { return VerifiedOperationalEvidence{}, errors.New("definition v3 requires operational evidence bundle v3") } @@ -373,7 +373,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase1, options.Phase1Close, - enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchema, + enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchemaV3, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase1 operational evidence: %w", err) @@ -384,7 +384,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase2, options.Phase2Close, - enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchema, + enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchemaV3, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err) diff --git a/internal/mpcceremony/operational_prepare.go b/internal/mpcceremony/operational_prepare.go index 4eff08f8..2a7a5470 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.Schema != DefinitionSchemaV3 { 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.Schema == DefinitionSchemaV3 { assurance = *definition.AssurancePolicy } var records []discoveredOperational 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) + } + } +} From e11c7a937b39a9bf007b59012c4cecbfdc84cded Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 02:47:46 +0900 Subject: [PATCH 04/53] Add versioned trusted-coordinator checkpoint state and delivery recovery --- cmd/mpc-ceremony/journey_inspection.go | 2 +- docs/ceremony-schema-compatibility.md | 32 +- internal/mpcceremony/audit.go | 5 +- internal/mpcceremony/chain.go | 2 +- internal/mpcceremony/checkpoint.go | 3 + internal/mpcceremony/checkpoint_v4.go | 583 ++++++++++++++++++++ internal/mpcceremony/checkpoint_v4_test.go | 436 +++++++++++++++ internal/mpcceremony/decision.go | 6 + internal/mpcceremony/definition.go | 139 +++-- internal/mpcceremony/definition_v4_test.go | 153 +++++ internal/mpcceremony/delivery_scope.go | 296 ++++++++++ internal/mpcceremony/delivery_scope_test.go | 263 +++++++++ internal/mpcceremony/model.go | 2 + internal/mpcceremony/operational.go | 4 +- internal/mpcceremony/operational_builder.go | 2 +- internal/mpcceremony/operational_bundle.go | 6 +- internal/mpcceremony/operational_prepare.go | 4 +- 17 files changed, 1868 insertions(+), 70 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4.go create mode 100644 internal/mpcceremony/checkpoint_v4_test.go create mode 100644 internal/mpcceremony/definition_v4_test.go create mode 100644 internal/mpcceremony/delivery_scope.go create mode 100644 internal/mpcceremony/delivery_scope_test.go diff --git a/cmd/mpc-ceremony/journey_inspection.go b/cmd/mpc-ceremony/journey_inspection.go index 4b277a4a..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.DefinitionSchemaV3 && 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/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 6caea5d9..cd880ee1 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -26,19 +26,43 @@ check by changing the meaning of V3 or a generic "current schema" constant. ## New-version implementation gate +Current draft: opt-in V4 definition construction and a separate V4 structural +checkpoint model exist in the library. Normal initialization still emits V3. +Do not release this slice alone: real-artifact V4 checkpoint authoring and the +new release/decision verification path are not complete. Structural fixture +tests are not a completed cryptographic ceremony or normal CLI journey. + - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed trust and submission rules; do not emit them until the whole verifier path exists and has negative tests. -- Give the changed ceremony release claim an explicit new version and trust - meaning. Do not silently change ordinary application key-bundle verification. -- Version final transcript and production decisions where they consume that - changed claim. Reuse component evidence/candidate formats only where their +- Give the changed ceremony release claim final transcript V3, with explicit + policy and the exact signed coordinator final-candidate checkpoint references. + Keep key manifest V1: its signed setup_transcript_hash binds the new transcript + without changing ordinary application key-bundle verification or adding a + second authorization signature. Include the checkpoint pair in the closed + ceremony release inventory. +- Explicitly dispatch Definition V4 to final transcript V3. Decision V2 may + retain its structure if exact definition/release binding and V4 verification + are enforced; it must not fall through to legacy policy. Reuse component + evidence/candidate formats only where their exact signed meaning stays unchanged. - Keep old signing and verification dispatch intact. Unknown versions fail closed; missing fields do not select the simplified path. - Coordinator replay stays mandatory. Release signer stays required; only its duplicate mathematical replay becomes optional in the new path. +V4 delivery history retains terminal dispositions. Its per-turn +`contribution_result_id` hashes the ceremony, phase, index, participant, +predecessor and fixed candidate-file labels/digests, not upload attempts or +paths. Retired delivery permits the same bytes to be redelivered; rejected +results cannot be accepted through replacement attempts. The signed history is +bounded to 16 attempts per logical submission and a separate 4,096-slot budget +across the ceremony. Validators reject excess history rather than dropping old +rejections. Retirement/rejection need not allocate a replacement, so exhausting +the budget does not prevent terminal retirement. Closure still requires the +signed contribution minimum. `VerifyCheckpointEdgeV4` compares the exact +predecessor record and signature; structural validation alone cannot do that. + The runtime has no dependency on a downstream delivery application's version. Provider keys, buckets and upload manifests belong outside proof-tool. Existing released coupling remains legacy behavior; new protocol outputs use logical diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 8f32d4d4..49b41ff1 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -675,6 +675,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") } @@ -1088,7 +1091,7 @@ func verifyPassingAudits( inputs []AuditArtifact, ) ([]ArtifactRef, time.Time, error) { minimum := 1 - if definition.Schema == DefinitionSchemaV3 { + if definition.UsesSignedAssurancePolicy() { minimum = int(definition.AssurancePolicy.PassingCeremonyAudits) } if len(inputs) < minimum { diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index ad0288b0..da9045d6 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -608,7 +608,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 != DefinitionSchemaV3 || + witnessesEnabled := !definition.UsesSignedAssurancePolicy() || (definition.AssurancePolicy != nil && definition.AssurancePolicy.PublicWitnessesPerPhase > 0) if definition.Mode == ModeProduction && witnessesEnabled { lead += time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second diff --git a/internal/mpcceremony/checkpoint.go b/internal/mpcceremony/checkpoint.go index b24aec6f..c64c8277 100644 --- a/internal/mpcceremony/checkpoint.go +++ b/internal/mpcceremony/checkpoint.go @@ -687,6 +687,9 @@ func VerifySignedCheckpoint(definition CeremonyDefinition, definitionBytes, defi } func validateCheckpointDefinitionVersion(definition CeremonyDefinition, checkpoint Checkpoint) error { + 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..92342f08 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4.go @@ -0,0 +1,583 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "reflect" + "slices" +) + +const ( + CheckpointSchemaV4 = "proof-tool-mpc-checkpoint-v4" + StorageFirstWorkflowV2 = "storage-first-v2" + MaxCheckpointSequenceV4 = 16384 + CheckpointDeliveryRetired CheckpointTransitionKind = "delivery-retired" + CheckpointContributionRejected CheckpointTransitionKind = "contribution-rejected" + CheckpointDeliveryReallocated CheckpointTransitionKind = "delivery-reallocated" +) + +// 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"` + FinalRelease *SignedArtifactRefs `json:"final_release,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"` + Record *SignedArtifactRefs `json:"record,omitempty"` + Evidence []ArtifactRef `json:"evidence"` + Contribution *CandidateInventory `json:"contribution,omitempty"` +} + +// 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 + } + } + } + if err := validateV4ArtifactSet(c.AcceptedArtifacts, MaxCheckpointArtifacts); err != nil { + return err + } + if err := ValidateDeliveryHistoryV2(c.Deliveries); err != nil { + return err + } + 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.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...) + 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 { + if err := c.Progress.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) != 5 { + return errors.New("initial checkpoint must contain exactly the signed definition and genesis chain/payload") + } + } + return nil +} + +func (t CheckpointTransitionV4) Validate() error { + if err := validateV4ArtifactSet(t.Evidence, MaxCheckpointArtifacts); err != nil { + return err + } + if t.Kind == CheckpointInitial { + if t.Scope != nil || t.AttemptID != "" || t.NextAttemptID != "" || t.Record != nil || t.Contribution != nil || len(t.Evidence) != 0 { + return errors.New("initial transition has extra fields") + } + return nil + } + turn := t.Kind == CheckpointPhase1OutboundPublished || t.Kind == CheckpointPhase2OutboundPublished || t.Kind == CheckpointPhase1ReceiptAccepted || t.Kind == CheckpointPhase2ReceiptAccepted || 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 + } + wantPhase := Phase1 + if t.Kind == CheckpointPhase2OutboundPublished || t.Kind == CheckpointPhase2ReceiptAccepted || t.Kind == CheckpointPhase2CandidateAccepted { + wantPhase = Phase2 + } + if t.Kind != CheckpointDeliveryRetired && t.Kind != CheckpointContributionRejected && t.Kind != CheckpointDeliveryReallocated && t.Scope.Phase != wantPhase { + return errors.New("transition kind and phase disagree") + } + replacement := t.Kind == CheckpointPhase1ReceiptAccepted || t.Kind == CheckpointPhase2ReceiptAccepted || 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") + } + } + if 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.Contribution != nil { + return errors.New("lifecycle transition must not contain turn fields") + } + switch t.Kind { + 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 CheckpointFinalCandidateRecorded, CheckpointFinalReleaseRecorded: + if len(t.Evidence) == 0 { + return errors.New("final transition requires its closed file inventory") + } + 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 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 CheckpointV4{}, errors.New("checkpoint changed its exact definition or signed policy") + } + for _, slot := range c.Deliveries { + if err := slot.Scope.ValidateAssignment(d); err != nil { + return CheckpointV4{}, err + } + } + if c.Transition.Scope != nil { + if err := c.Transition.Scope.ValidateAssignment(d); err != nil { + return CheckpointV4{}, 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 CheckpointV4{}, 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 CheckpointV4{}, errors.New("closure precedes required contribution minimum") + } + if c.Sequence == 0 && c.Progress.Phase1.HeadPayload != d.Phase1Genesis { + return CheckpointV4{}, errors.New("initial checkpoint changed definition genesis payload") + } + return c, nil +} + +func (p CheckpointProgressV4) currentTurn(scope ContributionScope) error { + 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 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 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.FinalRelease != nil { + return errors.New("final release requires a frozen final candidate") + } + 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 CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished: + if len(t.Evidence) != 0 { + return errors.New("outbound edge adds only its signed handoff") + } + for _, slot := range previous.Deliveries { + if slot.Status == DeliveryAllocated { + return errors.New("another delivery is still active") + } + } + want, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionReceipt, t.AttemptID) + case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: + if len(t.Evidence) != 0 { + return errors.New("receipt edge adds only its signed receipt") + } + if err = findActive(CheckpointSubmissionReceipt); err != nil { + return err + } + want, err = AdvanceDeliveryV2(previous.Deliveries, t.AttemptID, DeliveryAccepted, nil) + if err == nil { + want, err = AllocateDeliveryV2(want, scope, CheckpointSubmissionCandidate, t.NextAttemptID) + } + case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + 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 complete candidate plus coordinator verification record") + } + 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: + kind := CheckpointSubmissionCandidate + for _, slot := range previous.Deliveries { + if slot.AttemptID == t.AttemptID { + kind = slot.Kind + } + } + if err = findActive(kind); 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, kind, 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, old.Kind, 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_test.go b/internal/mpcceremony/checkpoint_v4_test.go new file mode 100644 index 00000000..f5aa7c57 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -0,0 +1,436 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "encoding/json" + "fmt" + "strings" + "testing" +) + +// 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, 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] + outbound, receipt, accept := CheckpointPhase1OutboundPublished, CheckpointPhase1ReceiptAccepted, CheckpointPhase1CandidateAccepted + if phase == Phase2 { + state = *start.Progress.Phase2 + participant = d.Phase2Policy.Participants[0] + outbound = CheckpointPhase2OutboundPublished + receipt = CheckpointPhase2ReceiptAccepted + accept = CheckpointPhase2CandidateAccepted + } + scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: phase, Index: state.AcceptedCount + 1, ParticipantID: participant, ParentHeadID: state.HeadRecordID} + id1, id2 := fmt.Sprintf("%032x", start.Sequence+100), fmt.Sprintf("%032x", start.Sequence+101) + handoff := checkpointSigned(string(phase) + "/outbound") + c1 := nextCheckpointV4(t, start, CheckpointTransitionV4{Kind: outbound, Scope: &scope, AttemptID: id1, Record: &handoff, Evidence: []ArtifactRef{}}) + var err error + c1.Deliveries, err = AllocateDeliveryV2(start.Deliveries, scope, CheckpointSubmissionReceipt, id1) + if err != nil { + t.Fatal(err) + } + r := checkpointSigned(string(phase) + "/receipt") + c2 := nextCheckpointV4(t, c1, CheckpointTransitionV4{Kind: receipt, Scope: &scope, AttemptID: id1, NextAttemptID: id2, Record: &r, Evidence: []ArtifactRef{}}) + c2.Deliveries, err = AdvanceDeliveryV2(c1.Deliveries, id1, DeliveryAccepted, nil) + if err != nil { + t.Fatal(err) + } + c2.Deliveries, err = AllocateDeliveryV2(c2.Deliveries, scope, CheckpointSubmissionCandidate, id2) + 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"))...) + c3 := nextCheckpointV4(t, c2, CheckpointTransitionV4{Kind: accept, Scope: &scope, AttemptID: id2, Record: &chain, Evidence: evidence, Contribution: &inventory}) + c3.Deliveries, err = AdvanceDeliveryV2(c2.Deliveries, id2, 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 { + c3.Progress.Phase1 = nextState + } else { + c3.Progress.Phase2 = &nextState + } + sequence := []CheckpointV4{start, c1, c2, c3} + 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, CheckpointFinalReleaseRecorded} + for _, kind := range stages { + record := checkpointSigned("lifecycle/" + string(kind)) + evidence := []ArtifactRef{} + if kind != CheckpointPhase1Closed && kind != CheckpointPhase2Closed { + evidence = append(evidence, checkpointArtifact("lifecycle/"+string(kind)+".bin", "payload")) + } + 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 + 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") + } +} + +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")) }, + "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[3]) + mutate(&n) + if err := ValidateCheckpointTransitionV4(turn[2], n); err == nil { + t.Fatal("invalid edge accepted") + } + }) + } + if err := ValidateCheckpointTransitionV4(turn[1], turn[3]); err == nil { + t.Fatal("receipt step skipped") + } + // A receipt allocation cannot be treated as an accepted candidate allocation. + n := cloneCheckpointV4(t, turn[3]) + n.Sequence = turn[1].Sequence + 1 + raw, _ := MarshalCanonical(turn[1]) + n.PreviousCheckpoint.Record.Digest = NewDigest(raw) + if err := ValidateCheckpointTransitionV4(turn[1], n); err == nil { + t.Fatal("candidate accepted before receipt") + } +} + +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[2] + for _, kind := range []CheckpointTransitionKind{CheckpointDeliveryRetired, CheckpointContributionRejected} { + t.Run(string(kind), func(t *testing.T) { + transition := CheckpointTransitionV4{Kind: kind, Scope: turn[3].Transition.Scope, AttemptID: turn[3].Transition.AttemptID, NextAttemptID: strings.Repeat("e", 32), Evidence: []ArtifactRef{}} + status := DeliveryRetired + if kind == CheckpointContributionRejected { + status = DeliveryRejected + transition.Contribution = turn[3].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[3].Transition + accept.AttemptID = transition.NextAttemptID + final := nextCheckpointV4(t, next, accept) + final.Progress = turn[3].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[3] + scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: Phase1, Index: 2, ParticipantID: d.Phase1Policy.Participants[1], ParentHeadID: previous.Progress.Phase1.HeadRecordID} + id := fmt.Sprintf("%032x", 200) + handoff := checkpointSigned("phase1/outbound-2") + c := nextCheckpointV4(t, previous, CheckpointTransitionV4{Kind: CheckpointPhase1OutboundPublished, Scope: &scope, AttemptID: id, Record: &handoff, Evidence: []ArtifactRef{}}) + var err error + c.Deliveries, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionReceipt, 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, CheckpointSubmissionReceipt, 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, CheckpointSubmissionReceipt, 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") + } +} diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index befd6566..aab636d6 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -856,6 +856,9 @@ 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") } @@ -902,6 +905,9 @@ func validateProductionDecisionBinding(definition CeremonyDefinition, decision P } func expectedFinalTranscriptSchema(definition CeremonyDefinition) string { + if definition.Schema == DefinitionSchemaV4 { + return FinalTranscriptSchemaV3 + } if definition.Schema == DefinitionSchemaV3 { return FinalTranscriptSchema } diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index d7311af1..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 DefinitionSchemaV3: + 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 == DefinitionSchemaV3 || 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 == DefinitionSchemaV3 && 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 == DefinitionSchemaV3 { + 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..fc895428 --- /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) == 7 { + expected = append(expected, "return-handoff.json", "return-handoff.sig") + } + if len(c.Files) != len(expected) { + return errors.New("candidate requires contribution, signed attestation, signed cleanup, and either both return-handoff files or neither") + } + 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]) + } + limit := int64(maxSignedRecordBytes) + if ref.Name == "contribution.bin" { + limit = MaxArtifactSize + } else if ref.Name == "attestation.sig" || ref.Name == "erasure.sig" || ref.Name == "return-handoff.sig" { + limit = 4096 + } + 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..bc6abdd4 --- /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 || got == want { + t.Fatalf("return custody not bound: %s %v", got, err) + } + // 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/model.go b/internal/mpcceremony/model.go index f175e6a9..1bc03b2b 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -22,6 +22,7 @@ const ( DefinitionSchemaV1 = "proof-tool-mpc-ceremony-definition-v1" DefinitionSchemaV2 = "proof-tool-mpc-ceremony-definition-v2" 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" @@ -34,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 2907c611..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 == DefinitionSchemaV3 && 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 == DefinitionSchemaV3 && 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 70b1c3b3..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 == DefinitionSchemaV3 && 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 9dd0448d..c772b5bf 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -328,7 +328,7 @@ 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 == DefinitionSchemaV3 { + if options.Definition.UsesSignedAssurancePolicy() { if bundle.Schema != OperationalEvidenceBundleSchema { return VerifiedOperationalEvidence{}, errors.New("definition v3 requires operational evidence bundle v3") } @@ -373,7 +373,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase1, options.Phase1Close, - enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchemaV3, + enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase1 operational evidence: %w", err) @@ -384,7 +384,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase2, options.Phase2Close, - enrollments, expectedAssurance, options.Definition.Schema != DefinitionSchemaV3, + enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err) diff --git a/internal/mpcceremony/operational_prepare.go b/internal/mpcceremony/operational_prepare.go index 2a7a5470..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 != DefinitionSchemaV3 { + 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 == DefinitionSchemaV3 { + if definition.UsesSignedAssurancePolicy() { assurance = *definition.AssurancePolicy } var records []discoveredOperational From fc8664c1f46377dc7a7b46928b6754517ca0eee0 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:06:35 +0900 Subject: [PATCH 05/53] Verify V4 checkpoint turn artifacts against replayed chain --- internal/mpcceremony/checkpoint_v4.go | 30 +- internal/mpcceremony/checkpoint_v4_files.go | 535 ++++++++++++++++++ .../mpcceremony/checkpoint_v4_files_test.go | 225 ++++++++ 3 files changed, 783 insertions(+), 7 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_files.go create mode 100644 internal/mpcceremony/checkpoint_v4_files_test.go diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 92342f08..42620040 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -287,29 +287,45 @@ func VerifySignedCheckpointV4(d CeremonyDefinition, definitionBytes, definitionS 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 CheckpointV4{}, errors.New("checkpoint changed its exact definition or signed policy") + return errors.New("checkpoint changed its exact definition or signed policy") } for _, slot := range c.Deliveries { if err := slot.Scope.ValidateAssignment(d); err != nil { - return CheckpointV4{}, err + return err } } if c.Transition.Scope != nil { if err := c.Transition.Scope.ValidateAssignment(d); err != nil { - return CheckpointV4{}, err + 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 CheckpointV4{}, errors.New("checkpoint contribution count exceeds signed schedule") + 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 CheckpointV4{}, errors.New("closure precedes required contribution minimum") + return errors.New("closure precedes required contribution minimum") } if c.Sequence == 0 && c.Progress.Phase1.HeadPayload != d.Phase1Genesis { - return CheckpointV4{}, errors.New("initial checkpoint changed definition genesis payload") + return errors.New("initial checkpoint changed definition genesis payload") } - return c, nil + return nil } func (p CheckpointProgressV4) currentTurn(scope ContributionScope) error { diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go new file mode 100644 index 00000000..bdd0697f --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -0,0 +1,535 @@ +package mpcceremony + +import ( + "bytes" + "crypto/sha256" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "reflect" + "slices" + "strings" + + "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 +} + +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 > maxSignedRecordBytes { + return nil, errors.New("large artifacts must be streamed, not retained in memory") + } + parts := strings.Split(ref.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(ref.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 actual != ref.Digest || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) { + return nil, 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 + outbound map[string]SignedArtifactRefs + count uint64 +} + +func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, definitionBytes, definitionSignature []byte, refs SignedArtifactRefs) (checkpointAncestryV4, error) { + result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}} + 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 + } + } + result.count++ + if current.Transition.Kind == CheckpointPhase1OutboundPublished || current.Transition.Kind == CheckpointPhase2OutboundPublished { + result.outbound[current.Transition.Record.Record.Digest.SHA256] = *current.Transition.Record + } + 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. +// The delivery service selects the current root; callers supply its exact pair. +func VerifyStoredCheckpointV4(trust TrustPaths, artifactRoot string, head SignedArtifactRefs) (CheckpointV4, error) { + trusted, err := LoadSignedDefinition(trust) + if err != nil { + return CheckpointV4{}, err + } + db, err := MarshalCanonical(trusted.Definition) + if err != nil { + return CheckpointV4{}, err + } + ds, err := readRegularBounded(trust.DefinitionSignaturePath, 4096) + if err != nil { + return CheckpointV4{}, err + } + reader, err := openCheckpointReaderV4(artifactRoot) + if err != nil { + return CheckpointV4{}, err + } + defer reader.root.Close() + ancestry, err := loadCheckpointAncestryV4(reader, trusted.Definition, db, ds, head) + if err != nil { + return CheckpointV4{}, err + } + return ancestry.head, 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 + // 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 reader.root.Close() + var previous *CheckpointV4 + outbound := map[string]SignedArtifactRefs{} + if c.PreviousCheckpoint != nil { + ancestry, err := loadCheckpointAncestryV4(reader, d, db, ds, *c.PreviousCheckpoint) + if err != nil { + return nil, err + } + previous = &ancestry.head + outbound = ancestry.outbound + if err := ValidateCheckpointTransitionV4(*previous, c); err != nil { + return nil, err + } + } + // Check every newly accepted byte before issuing any signable result. + 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 _, err := reader.read(ref, limit, false); err != nil { + return nil, err + } + } + if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, outbound); err != nil { + return nil, err + } + return MarshalCanonical(c) +} + +func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous *CheckpointV4, outbound map[string]SignedArtifactRefs) 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 CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished: + _, err := verifyOutboundHandoffV4(reader, d, *previous, *c.Transition.Scope, *c.Transition.Record) + return err + case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: + return verifyOutboundReceiptV4(reader, d, *previous, c.Transition, outbound) + case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + return verifyAcceptedCandidateV4(options, trusted, reader, *previous) + case CheckpointContributionRejected: + return verifyRejectedInventoryV4(options.RejectedCandidateDir, *c.Transition.Contribution) + case CheckpointDeliveryRetired, CheckpointDeliveryReallocated: + return nil // No protocol claim or accepted artifact is added. + 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 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) 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") + } + if len(c.Transition.Contribution.Files) == 7 { + return verifyReturnHandoffV4(reader, trusted.Definition, scope, *c.Transition.Contribution) + } + 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 verifyReturnHandoffV4(reader *checkpointReaderV4, d CeremonyDefinition, scope ContributionScope, inventory CandidateInventory) error { + base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) + refs := SignedArtifactRefs{Record: ArtifactRef{Name: base + inventory.Files[5].Name, Digest: inventory.Files[5].Digest}, Signature: ArtifactRef{Name: base + inventory.Files[6].Name, Digest: inventory.Files[6].Digest}} + record, signature, err := reader.pair(refs) + if err != nil { + return err + } + participant, ok := d.ParticipantByID(scope.ParticipantID) + if !ok { + return errors.New("return sender is not in roster") + } + key, err := identityPublicKey(participant.Identity) + if err != nil { + return err + } + var handoff TransferHandoff + if err := VerifySignedRecord(record, signature, &handoff, participant.Identity.KeyID, key); err != nil { + return err + } + if err := verifyTransferSource(d, handoff.Source); err != nil { + return err + } + expected := append([]ArtifactRef{}, inventory.Files[:5]...) + for i := range expected { + expected[i].Name = base + expected[i].Name + } + if handoff.CeremonyID != d.CeremonyID || handoff.Phase != scope.Phase || handoff.Index != scope.Index || handoff.PredecessorHeadID != scope.ParentHeadID || handoff.SenderID != scope.ParticipantID || handoff.SenderKeyID != participant.Identity.KeyID || handoff.RecipientID != d.Coordinator.ID || handoff.RecipientKeyID != d.Coordinator.KeyID || !slices.Equal(handoff.Files, expected) { + return errors.New("return handoff does not bind this participant and complete candidate") + } + return nil +} + +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..bbdb1244 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -0,0 +1,225 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" +) + +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 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 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) + } + 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") + } +} From 36e4f4776f6c3b123827067f01ddfc34bc2d8d54 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:12:32 +0900 Subject: [PATCH 06/53] Exercise V4 checkpoints with a real signed contribution turn --- .../mpcceremony/checkpoint_v4_files_test.go | 39 +++ .../testdata/workflowhelper/checkpoint_v4.go | 225 ++++++++++++++++++ .../testdata/workflowhelper/main.go | 27 ++- 3 files changed, 282 insertions(+), 9 deletions(-) create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index bbdb1244..346663b4 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -6,11 +6,50 @@ import ( "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) + } + run := exec.Command(helper, filepath.Join(t.TempDir(), "ceremony-run")) + 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") + output, err := run.CombinedOutput() + if err != nil { + t.Fatalf("real checkpoint turn: %v\n%s", err, output) + } + if !strings.Contains(string(output), "V4 real phase1 turn passed") { + t.Fatalf("missing completion: %s", output) + } +} + func putCheckpointTestFileV4(t *testing.T, root, name string, data []byte) ArtifactRef { t.Helper() path := filepath.Join(root, name) diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go new file mode 100644 index 00000000..284024b2 --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -0,0 +1,225 @@ +package main + +import ( + "crypto/ed25519" + "fmt" + "os" + "path/filepath" + "runtime" + "sort" + + 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 + } + definition, err := pair("ceremony") + if err != nil { + return err + } + c := 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: sorted([]m.ArtifactRef{definition.Record, definition.Signature, chainRefs.Record, chainRefs.Signature, payload}), Deliveries: []m.DeliverySlotV2{}} + var committed m.SignedArtifactRefs + commit := func() error { + if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: c, Circuit: circuit}); 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...) + c.AcceptedArtifacts = sorted(refs) + } + if err := commit(); err != nil { + return err + } + p := d.Roster[0].Identity + scope := m.ContributionScope{CeremonyID: d.CeremonyID, Phase: m.Phase1, Index: 1, ParticipantID: p.ID, ParentHeadID: head} + handoff, err := m.NewTransferHandoff(d, m.Phase1, 1, head, []m.ArtifactRef{payload}, d.Coordinator, p, "2023-08-23T15:01:00Z", "2023-08-23T16:01:00Z") + if err != nil { + return err + } + handoffRefs, err := writePair("custody/outbound", handoff, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + const first = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" + const second = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" + const candidateAttempt = "cccccccccccccccccccccccccccccccc" + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1OutboundPublished, Scope: &scope, AttemptID: first, Record: &handoffRefs, Evidence: []m.ArtifactRef{}}) + c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionReceipt, first) + if err != nil { + return err + } + if err = commit(); err != nil { + return err + } + // Retire without replacement, then reallocate. The receipt still binds the + // original signed handoff, not a transport-attempt envelope. + next(m.CheckpointTransitionV4{Kind: m.CheckpointDeliveryRetired, Scope: &scope, AttemptID: first, Evidence: []m.ArtifactRef{}}) + c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, first, m.DeliveryRetired, nil) + if err != nil { + return err + } + if err = commit(); err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointDeliveryReallocated, Scope: &scope, AttemptID: first, NextAttemptID: second, Evidence: []m.ArtifactRef{}}) + c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionReceipt, second) + if err != nil { + return err + } + if err = commit(); err != nil { + return err + } + hb, err := os.ReadFile(filepath.Join(root, handoffRefs.Record.Name)) + if err != nil { + return err + } + receipt, err := m.NewTransferReceipt(handoff, hb, m.ReceiptReceiver, "2023-08-23T15:02:00Z") + if err != nil { + return err + } + receiptRefs, err := writePair("custody/receipt", receipt, p.KeyID, participant) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1ReceiptAccepted, Scope: &scope, AttemptID: second, NextAttemptID: candidateAttempt, Record: &receiptRefs, Evidence: []m.ArtifactRef{}}) + c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, second, m.DeliveryAccepted, nil) + if err != nil { + return err + } + c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionCandidate, candidateAttempt) + if err != nil { + return err + } + 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} + if _, err = m.CreateContributionCandidate(m.ContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase1, Transcript: paths, ParticipantID: p.ID, ParticipantPrivateKeyPath: participantPath, Environment: environment, ContributedAt: "2023-08-23T15:03:00Z", 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:04:00Z"}); err != nil { + return err + } + accepted, err := m.VerifyAndAcceptContribution(m.AcceptContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase1, Transcript: paths, CandidateDir: candidateDir, CoordinatorPrivateKeyPath: coordinatorPath, AcceptedAt: "2023-08-23T15:05:00Z"}) + if err != nil { + return err + } + 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) + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1CandidateAccepted, Scope: &scope, AttemptID: candidateAttempt, Record: &chainRefs, Evidence: sorted(append(files, last.Verification)), Contribution: &inventory}) + c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, candidateAttempt, m.DeliveryAccepted, &inventory) + 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.Phase1 = m.CheckpointPhaseState{Phase: m.Phase1, AcceptedCount: 1, HeadRecordID: head, HeadPayload: payload, Chain: chainRefs} + 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") + } + fmt.Println("V4 real phase1 turn passed: initial, outbound, retirement, reallocation, receipt, contribution, cleanup, full replay, exact acceptance, corruption rejected") + return nil +} diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 1cdca52f..29a0ff38 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -65,11 +65,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( @@ -228,18 +229,23 @@ func run(outputRoot, operationalEvidenceHelper string) error { assurance.MirrorsPerAcceptedHead = 1 assurance.PassingCeremonyAudits = 1 } + releaseVerification := "" + if checkpointV4 { + releaseVerification = mpcceremony.CoordinatorReplayReleaseV1 + } 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 +286,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, From b2ba15bbe86e4d12fde16b3afe8f5fb7c7cfc977 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:26:31 +0900 Subject: [PATCH 07/53] Bind V4 custody evidence and verify phase lifecycle artifacts --- docs/ceremony-schema-compatibility.md | 13 +- internal/mpcceremony/checkpoint_v4.go | 12 +- internal/mpcceremony/checkpoint_v4_files.go | 109 ++++++++++- .../mpcceremony/checkpoint_v4_files_test.go | 57 +++++- .../mpcceremony/checkpoint_v4_lifecycle.go | 160 ++++++++++++++++ internal/mpcceremony/checkpoint_v4_test.go | 17 +- .../testdata/workflowhelper/checkpoint_v4.go | 179 +++++++++++++++++- .../testdata/workflowhelper/main.go | 2 +- 8 files changed, 528 insertions(+), 21 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_lifecycle.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index cd880ee1..bd6f32aa 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -26,11 +26,14 @@ check by changing the meaning of V3 or a generic "current schema" constant. ## New-version implementation gate -Current draft: opt-in V4 definition construction and a separate V4 structural -checkpoint model exist in the library. Normal initialization still emits V3. -Do not release this slice alone: real-artifact V4 checkpoint authoring and the -new release/decision verification path are not complete. Structural fixture -tests are not a completed cryptographic ceremony or normal CLI journey. +Current draft: opt-in V4 construction, structural checkpoints and real-artifact +verification through Phase 2 initialization exist in the library. The Linux +integration test uses a real tiny contribution, signed custody records, genuine +historical drand response, Phase 1 replay/sealing and Phase 2 genesis. Its +environment and cleanup claims are test fixtures, not physical assurance. +Normal initialization still emits V3. Do not release this slice alone: remaining +evidence gates, final authoring and the new release/decision verification path +are incomplete. These tests are not the normal CLI journey or a full ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed trust and submission rules; do not emit them until the whole verifier path diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 42620040..08485c51 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -485,6 +485,9 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { want, err = AllocateDeliveryV2(want, scope, CheckpointSubmissionCandidate, t.NextAttemptID) } case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + if len(t.Contribution.Files) != 7 { + return errors.New("candidate acceptance requires the signed return handoff") + } if err = findActive(CheckpointSubmissionCandidate); err != nil { return err } @@ -500,8 +503,8 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { 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 complete candidate plus coordinator verification record") + if len(t.Evidence) != len(t.Contribution.Files)+3 { + return errors.New("candidate acceptance requires complete candidate, verification and signed return receipt") } for _, ref := range t.Contribution.Files { logical := ArtifactRef{Name: base + ref.Name, Digest: ref.Digest} @@ -515,6 +518,11 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { if !slices.ContainsFunc(t.Evidence, func(ref ArtifactRef) bool { return ref.Name == base+"verification.json" }) { return errors.New("candidate acceptance lacks coordinator verification record") } + for _, name := range []string{"return-receipt.json", "return-receipt.sig"} { + if !slices.ContainsFunc(t.Evidence, func(ref ArtifactRef) bool { return ref.Name == base+name }) { + return errors.New("candidate acceptance lacks signed return receipt") + } + } if scope.Phase == Phase1 { wantProgress.Phase1 = state } else { diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index bdd0697f..a54290da 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -11,6 +11,7 @@ import ( "reflect" "slices" "strings" + "time" "golang.org/x/crypto/blake2b" ) @@ -127,11 +128,12 @@ func (r *checkpointReaderV4) pair(refs SignedArtifactRefs) ([]byte, []byte, erro type checkpointAncestryV4 struct { head CheckpointV4 outbound map[string]SignedArtifactRefs + receipts map[ContributionScope]SignedArtifactRefs count uint64 } func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, definitionBytes, definitionSignature []byte, refs SignedArtifactRefs) (checkpointAncestryV4, error) { - result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}} + result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}} var child *CheckpointV4 for { if result.count > MaxCheckpointSequenceV4 { @@ -154,6 +156,9 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } } result.count++ + if current.Transition.Kind == CheckpointPhase1ReceiptAccepted || current.Transition.Kind == CheckpointPhase2ReceiptAccepted { + result.receipts[*current.Transition.Scope] = *current.Transition.Record + } if current.Transition.Kind == CheckpointPhase1OutboundPublished || current.Transition.Kind == CheckpointPhase2OutboundPublished { result.outbound[current.Transition.Record.Record.Digest.SHA256] = *current.Transition.Record } @@ -235,6 +240,7 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { defer reader.root.Close() var previous *CheckpointV4 outbound := map[string]SignedArtifactRefs{} + receipts := map[ContributionScope]SignedArtifactRefs{} if c.PreviousCheckpoint != nil { ancestry, err := loadCheckpointAncestryV4(reader, d, db, ds, *c.PreviousCheckpoint) if err != nil { @@ -242,6 +248,7 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { } previous = &ancestry.head outbound = ancestry.outbound + receipts = ancestry.receipts if err := ValidateCheckpointTransitionV4(*previous, c); err != nil { return nil, err } @@ -261,13 +268,13 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { return nil, err } } - if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, outbound); err != nil { + if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, outbound, receipts); err != nil { return nil, err } return MarshalCanonical(c) } -func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous *CheckpointV4, outbound map[string]SignedArtifactRefs) error { +func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous *CheckpointV4, outbound map[string]SignedArtifactRefs, receipts map[ContributionScope]SignedArtifactRefs) error { c := options.Proposal d := trusted.Definition switch c.Transition.Kind { @@ -283,11 +290,13 @@ func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *Truste case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: return verifyOutboundReceiptV4(reader, d, *previous, c.Transition, outbound) case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: - return verifyAcceptedCandidateV4(options, trusted, reader, *previous) + return verifyAcceptedCandidateV4(options, trusted, reader, *previous, outbound, receipts) 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) default: return errors.New("real-artifact authoring for this v4 transition is not implemented yet") } @@ -331,7 +340,7 @@ func verifyRejectedInventoryV4(dir string, inventory CandidateInventory) error { return nil } -func verifyAcceptedCandidateV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous CheckpointV4) error { +func verifyAcceptedCandidateV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous CheckpointV4, outbound map[string]SignedArtifactRefs, receipts map[ContributionScope]SignedArtifactRefs) error { c := options.Proposal scope := *c.Transition.Scope before, after := previous.Progress.Phase1, c.Progress.Phase1 @@ -379,8 +388,94 @@ func verifyAcceptedCandidateV4(options CheckpointPreparationV4, trusted *Trusted if last.ParticipantID != scope.ParticipantID { return errors.New("accepted chain names another participant") } - if len(c.Transition.Contribution.Files) == 7 { - return verifyReturnHandoffV4(reader, trusted.Definition, scope, *c.Transition.Contribution) + if err := verifyReturnHandoffV4(reader, trusted.Definition, scope, *c.Transition.Contribution); err != nil { + return err + } + return verifyCandidateCustodyV4(reader, trusted, previous, c.Transition, chain, outbound, receipts) +} + +func verifyCandidateCustodyV4(reader *checkpointReaderV4, trusted *TrustedCeremony, previous CheckpointV4, tx CheckpointTransitionV4, chain Chain, outbound map[string]SignedArtifactRefs, receipts map[ContributionScope]SignedArtifactRefs) error { + scope := *tx.Scope + inputRefs, ok := receipts[scope] + if !ok { + return errors.New("candidate has no committed outbound receipt") + } + inputTx := CheckpointTransitionV4{Scope: &scope, Record: &inputRefs} + if err := verifyOutboundReceiptV4(reader, trusted.Definition, previous, inputTx, outbound); err != nil { + return err + } + read := func(ref ArtifactRef, out any) error { + b, err := reader.read(ref, maxSignedRecordBytes, true) + if err != nil { + return err + } + return UnmarshalCanonical(b, out) + } + var inputReceipt TransferReceipt + if err := read(inputRefs.Record, &inputReceipt); err != nil { + return err + } + var inputHandoff TransferHandoff + if err := read(outbound[inputReceipt.HandoffSHA256].Record, &inputHandoff); err != nil { + return err + } + base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) + get := func(name string) ArtifactRef { + for _, ref := range tx.Evidence { + if ref.Name == base+name { + return ref + } + } + return ArtifactRef{} + } + handoffBytes, err := reader.read(get("return-handoff.json"), maxSignedRecordBytes, true) + if err != nil { + return err + } + var handoff TransferHandoff + if err = UnmarshalCanonical(handoffBytes, &handoff); err != nil { + return err + } + rb, rs, err := reader.pair(SignedArtifactRefs{Record: get("return-receipt.json"), Signature: get("return-receipt.sig")}) + if err != nil { + return err + } + var receipt TransferReceipt + if err = VerifySignedRecord(rb, rs, &receipt, trusted.Definition.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { + return err + } + if receipt.Kind != ReceiptReceiver { + return errors.New("return receipt must be the coordinator receiver receipt") + } + if err = VerifyTransferReceipt(handoffBytes, handoff, receipt); err != nil { + return err + } + 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 + } + predecessor := trusted.Definition.CreatedAt + if len(chain.Records) > 1 { + predecessor = chain.Records[len(chain.Records)-2].AcceptedAt + } + timestamps := []string{predecessor, inputHandoff.CreatedAt, inputReceipt.ReceivedAt, attestation.ContributedAt, erasure.DestroyedAt, handoff.CreatedAt, receipt.ReceivedAt, 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; custody handoffs/receipts must be strictly later. + if i > 0 && ((i == 4 && parsed.Before(before)) || (i != 4 && !parsed.After(before))) { + return errors.New("candidate custody, cleanup and acceptance timestamps are not strictly ordered") + } + before = parsed } return nil } diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 346663b4..2b198d5a 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -45,11 +45,66 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { if err != nil { t.Fatalf("real checkpoint turn: %v\n%s", err, output) } - if !strings.Contains(string(output), "V4 real phase1 turn passed") { + if !strings.Contains(string(output), "V4 real phase1 turn passed") || !strings.Contains(string(output), "closure, drand, seal, phase2 genesis") { t.Fatalf("missing completion: %s", output) } } +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) 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_test.go b/internal/mpcceremony/checkpoint_v4_test.go index f5aa7c57..5c7d1253 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -103,6 +103,7 @@ func checkpointTurnV4(t *testing.T, d CeremonyDefinition, start CheckpointV4, ph } _, inventory := candidateInventoryFixture(t) inventory.Scope = scope + inventory.Files = append(inventory.Files, inventoryTestRef("return-handoff.json", []byte("handoff")), inventoryTestRef("return-handoff.sig", []byte("handoff signature"))) chain := checkpointSigned(fmt.Sprintf("%s/chain-%04d", phase, scope.Index)) evidence := []ArtifactRef{} for _, ref := range inventory.Files { @@ -110,6 +111,7 @@ func checkpointTurnV4(t *testing.T, d CeremonyDefinition, start CheckpointV4, ph evidence = append(evidence, ref) } evidence = checkpointArtifacts(append(evidence, checkpointArtifact(fmt.Sprintf("%s/contributions/%04d/verification.json", phase, scope.Index), "verification"))...) + evidence = checkpointArtifacts(append(evidence, checkpointArtifact(fmt.Sprintf("%s/contributions/%04d/return-receipt.json", phase, scope.Index), "receipt"), checkpointArtifact(fmt.Sprintf("%s/contributions/%04d/return-receipt.sig", phase, scope.Index), "receipt signature"))...) c3 := nextCheckpointV4(t, c2, CheckpointTransitionV4{Kind: accept, Scope: &scope, AttemptID: id2, Record: &chain, Evidence: evidence, Contribution: &inventory}) c3.Deliveries, err = AdvanceDeliveryV2(c2.Deliveries, id2, DeliveryAccepted, &inventory) if err != nil { @@ -185,9 +187,18 @@ func TestCheckpointV4RejectsSkippedOrAlteredTurnEdges(t *testing.T) { "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")) }, + "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")) }, + "five file candidate": func(n *CheckpointV4) { n.Transition.Contribution.Files = n.Transition.Contribution.Files[:5] }, + "missing return receipt": func(n *CheckpointV4) { + for i, ref := range n.Transition.Evidence { + if strings.HasSuffix(ref.Name, "return-receipt.sig") { + 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 }, } { diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 284024b2..79b1667c 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -7,6 +7,8 @@ import ( "path/filepath" "runtime" "sort" + "strings" + "time" m "proof-tool/internal/mpcceremony" ) @@ -168,6 +170,34 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com 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 } + returnFiles := []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 + } + returnFiles = append(returnFiles, m.ArtifactRef{Name: "phase1/contributions/0001/" + name, Digest: m.NewDigest(b)}) + } + returnHandoff, err := m.NewTransferHandoff(d, m.Phase1, 1, scope.ParentHeadID, returnFiles, p, d.Coordinator, "2023-08-23T15:04:10Z", "2023-08-23T16:04:10Z") + if err != nil { + return err + } + returnRefs, err := writePair("custody/return-handoff", returnHandoff, p.KeyID, participant) + if err != nil { + return err + } + rhBytes, err := os.ReadFile(filepath.Join(root, returnRefs.Record.Name)) + if err != nil { + return err + } + returnReceipt, err := m.NewTransferReceipt(returnHandoff, rhBytes, m.ReceiptReceiver, "2023-08-23T15:04:20Z") + if err != nil { + return err + } + returnReceiptRefs, err := writePair("custody/return-receipt", returnReceipt, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } accepted, err := m.VerifyAndAcceptContribution(m.AcceptContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase1, Transcript: paths, CandidateDir: candidateDir, CoordinatorPrivateKeyPath: coordinatorPath, AcceptedAt: "2023-08-23T15:05:00Z"}) if err != nil { return err @@ -180,11 +210,28 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com } last := chain.Records[0] files := []m.ArtifactRef{last.Attestation, last.AttestationSignature, last.OutputPayload, last.Erasure, last.ErasureSignature} + returnEvidence := []m.ArtifactRef{} + for _, pair := range []m.SignedArtifactRefs{returnRefs, returnReceiptRefs} { + for _, original := range []m.ArtifactRef{pair.Record, pair.Signature} { + b, err := os.ReadFile(filepath.Join(root, original.Name)) + if err != nil { + return err + } + name := "phase1/contributions/0001/" + filepath.Base(original.Name) + if err = os.WriteFile(filepath.Join(root, name), b, 0600); err != nil { + return err + } + returnEvidence = append(returnEvidence, m.ArtifactRef{Name: name, Digest: m.NewDigest(b)}) + } + } + files = append(files, returnEvidence[:2]...) 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) } - next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1CandidateAccepted, Scope: &scope, AttemptID: candidateAttempt, Record: &chainRefs, Evidence: sorted(append(files, last.Verification)), Contribution: &inventory}) + evidence := append(append([]m.ArtifactRef{}, files...), returnEvidence[2:]...) + evidence = append(evidence, last.Verification) + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1CandidateAccepted, Scope: &scope, AttemptID: candidateAttempt, Record: &chainRefs, Evidence: sorted(evidence), Contribution: &inventory}) c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, candidateAttempt, m.DeliveryAccepted, &inventory) if err != nil { return err @@ -198,6 +245,34 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com return err } c.Progress.Phase1 = m.CheckpointPhaseState{Phase: m.Phase1, AcceptedCount: 1, HeadRecordID: head, HeadPayload: payload, Chain: chainRefs} + lateReceipt := returnReceipt + lateReceipt.ReceivedAt = "2023-08-23T15:06:00Z" + lateRefs, err := writePair("phase1/contributions/0001/return-receipt", lateReceipt, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + bad := c + bad.Transition.Evidence = append([]m.ArtifactRef{}, c.Transition.Evidence...) + bad.AcceptedArtifacts = append([]m.ArtifactRef{}, c.AcceptedArtifacts...) + for _, replacement := range []m.ArtifactRef{lateRefs.Record, lateRefs.Signature} { + for i := range bad.Transition.Evidence { + if bad.Transition.Evidence[i].Name == replacement.Name { + bad.Transition.Evidence[i] = replacement + } + } + for i := range bad.AcceptedArtifacts { + if bad.AcceptedArtifacts[i].Name == replacement.Name { + bad.AcceptedArtifacts[i] = replacement + } + } + } + _, lateErr := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: bad, Circuit: circuit}) + if lateErr == nil || !strings.Contains(lateErr.Error(), "timestamps") { + return fmt.Errorf("late signed return receipt: expected custody chronology rejection, got %v", lateErr) + } + if _, err = writePair("phase1/contributions/0001/return-receipt", returnReceipt, d.Coordinator.KeyID, coordinator); err != nil { + return err + } if err = commit(); err != nil { return err } @@ -220,6 +295,106 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if rejectErr == nil { return fmt.Errorf("corrupted accepted contribution passed checkpoint preparation") } - fmt.Println("V4 real phase1 turn passed: initial, outbound, retirement, reallocation, receipt, contribution, cleanup, full replay, exact acceptance, corruption rejected") + // 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 err = commit(); err != nil { + return err + } + raw := filepath.Join(output, "quicknet-v4-42.json") + 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, outbound, retirement, reallocation, receipt, contribution, cleanup, full replay, exact acceptance, corruption rejected, closure, drand, seal, phase2 genesis") return nil } diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 29a0ff38..cab09e98 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -215,7 +215,7 @@ 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 { From 0cee66eaa21da38d09daf183585867a20f5c968c Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:35:37 +0900 Subject: [PATCH 08/53] Record verified enrollments and per-head mirror evidence in V4 --- internal/mpcceremony/checkpoint_v4.go | 22 +++ .../mpcceremony/checkpoint_v4_enrollments.go | 73 ++++++++++ .../checkpoint_v4_enrollments_test.go | 128 ++++++++++++++++++ internal/mpcceremony/checkpoint_v4_files.go | 63 ++++++++- .../mpcceremony/checkpoint_v4_files_test.go | 34 +++-- internal/mpcceremony/checkpoint_v4_mirrors.go | 91 +++++++++++++ internal/mpcceremony/operational_bundle.go | 3 +- .../testdata/workflowhelper/checkpoint_v4.go | 97 +++++++++++++ .../testdata/workflowhelper/main.go | 3 + 9 files changed, 493 insertions(+), 21 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_enrollments.go create mode 100644 internal/mpcceremony/checkpoint_v4_enrollments_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_mirrors.go diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 08485c51..8973d7c2 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -14,6 +14,8 @@ const ( CheckpointDeliveryRetired CheckpointTransitionKind = "delivery-retired" CheckpointContributionRejected CheckpointTransitionKind = "contribution-rejected" CheckpointDeliveryReallocated CheckpointTransitionKind = "delivery-reallocated" + CheckpointEnrollmentRecorded CheckpointTransitionKind = "enrollment-recorded" + CheckpointMirrorRecorded CheckpointTransitionKind = "mirror-recorded" ) // CheckpointProgressV4 is the protocol projection used for guidance. It is not @@ -240,6 +242,14 @@ func (t CheckpointTransitionV4) Validate() error { return errors.New("lifecycle transition must not contain turn fields") } switch t.Kind { + case CheckpointEnrollmentRecorded: + if len(t.Evidence) != 1 { + return errors.New("enrollment transition requires its disclosure artifact") + } + case CheckpointMirrorRecorded: + if len(t.Evidence) != 0 { + return errors.New("mirror edge adds only its signed receipt") + } case CheckpointPhase1Closed, CheckpointPhase2Closed: if len(t.Evidence) != 0 { return errors.New("closure transition only adds the signed closure") @@ -372,6 +382,18 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { return errors.New("accepted artifact inventory must remain append-only") } t := next.Transition + if t.Kind == CheckpointEnrollmentRecorded || t.Kind == CheckpointMirrorRecorded { + if previous.Progress.FinalRelease != nil { + return errors.New("cannot add assurance evidence after final release") + } + 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") 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..af11ebdc --- /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 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 index a54290da..57527269 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -126,14 +126,17 @@ func (r *checkpointReaderV4) pair(refs SignedArtifactRefs) ([]byte, []byte, erro } type checkpointAncestryV4 struct { - head CheckpointV4 - outbound map[string]SignedArtifactRefs - receipts map[ContributionScope]SignedArtifactRefs - count uint64 + head CheckpointV4 + outbound map[string]SignedArtifactRefs + receipts map[ContributionScope]SignedArtifactRefs + enrollments []SignedArtifactRefs + mirrors []SignedArtifactRefs + accepted map[ContributionScope]SignedArtifactRefs + count uint64 } func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, definitionBytes, definitionSignature []byte, refs SignedArtifactRefs) (checkpointAncestryV4, error) { - result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}} + result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}, accepted: map[ContributionScope]SignedArtifactRefs{}} var child *CheckpointV4 for { if result.count > MaxCheckpointSequenceV4 { @@ -156,6 +159,15 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } } result.count++ + if current.Transition.Kind == CheckpointMirrorRecorded { + result.mirrors = append(result.mirrors, *current.Transition.Record) + } + if current.Transition.Kind == CheckpointPhase1CandidateAccepted || current.Transition.Kind == CheckpointPhase2CandidateAccepted { + result.accepted[*current.Transition.Scope] = *current.Transition.Record + } + if current.Transition.Kind == CheckpointEnrollmentRecorded { + result.enrollments = append(result.enrollments, *current.Transition.Record) + } if current.Transition.Kind == CheckpointPhase1ReceiptAccepted || current.Transition.Kind == CheckpointPhase2ReceiptAccepted { result.receipts[*current.Transition.Scope] = *current.Transition.Record } @@ -241,6 +253,8 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { var previous *CheckpointV4 outbound := map[string]SignedArtifactRefs{} receipts := map[ContributionScope]SignedArtifactRefs{} + enrollments := []SignedArtifactRefs{} + var evidenceAncestry checkpointAncestryV4 if c.PreviousCheckpoint != nil { ancestry, err := loadCheckpointAncestryV4(reader, d, db, ds, *c.PreviousCheckpoint) if err != nil { @@ -249,6 +263,8 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { previous = &ancestry.head outbound = ancestry.outbound receipts = ancestry.receipts + enrollments = ancestry.enrollments + evidenceAncestry = ancestry if err := ValidateCheckpointTransitionV4(*previous, c); err != nil { return nil, err } @@ -268,6 +284,43 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { 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 == 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 == CheckpointPhase1OutboundPublished || c.Transition.Kind == CheckpointPhase2OutboundPublished { + if _, ok := verifiedEnrollments[c.Transition.Scope.ParticipantID]; !ok { + return nil, errors.New("participant enrollment must be committed before outbound delivery") + } + } if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, outbound, receipts); err != nil { return nil, err } diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 2b198d5a..74fe1875 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -32,21 +32,25 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { if output, err := build.CombinedOutput(); err != nil { t.Fatalf("build: %v\n%s", err, output) } - run := exec.Command(helper, filepath.Join(t.TempDir(), "ceremony-run")) - 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") - output, err := run.CombinedOutput() - 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), "closure, drand, seal, phase2 genesis") { - t.Fatalf("missing completion: %s", output) + for _, mirrorMode := range []string{"0", "1"} { + t.Run("mirrors-"+mirrorMode, func(t *testing.T) { + run := exec.Command(helper, filepath.Join(t.TempDir(), "ceremony-run")) + 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="+mirrorMode) + output, err := run.CombinedOutput() + 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), "closure, drand, seal, phase2 genesis") { + t.Fatalf("missing completion: %s", output) + } + }) } } 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/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index c772b5bf..1f01ba2b 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -10,6 +10,7 @@ import ( ) const ( + maxEnrollmentDisclosureBytes = 1 << 20 OperationalEvidenceBundleSchemaV2 = "proof-tool-mpc-operational-evidence-bundle-v2" OperationalEvidenceBundleSchema = "proof-tool-mpc-operational-evidence-bundle-v3" ) @@ -811,7 +812,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 { diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 79b1667c..ff1805bd 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -1,6 +1,7 @@ package main import ( + "bytes" "crypto/ed25519" "fmt" "os" @@ -100,6 +101,35 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com 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} handoff, err := m.NewTransferHandoff(d, m.Phase1, 1, head, []m.ArtifactRef{payload}, d.Coordinator, p, "2023-08-23T15:01:00Z", "2023-08-23T16:01:00Z") if err != nil { @@ -117,6 +147,13 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if err != nil { return err } + missingEnrollment := c + missingEnrollment.Sequence = beforeEnrollment.Sequence + 1 + missingEnrollment.PreviousCheckpoint = &beforeEnrollmentRefs + missingEnrollment.AcceptedArtifacts = sorted(append(append([]m.ArtifactRef{}, beforeEnrollment.AcceptedArtifacts...), handoffRefs.Record, handoffRefs.Signature)) + if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: missingEnrollment, Circuit: circuit}); err == nil || !strings.Contains(err.Error(), "enrollment") { + return fmt.Errorf("outbound without committed enrollment: %v", err) + } if err = commit(); err != nil { return err } @@ -295,6 +332,57 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com 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) @@ -315,6 +403,15 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com } 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 } diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index cab09e98..297cf211 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -232,6 +232,9 @@ func run(outputRoot, operationalEvidenceHelper string) error { releaseVerification := "" if checkpointV4 { releaseVerification = mpcceremony.CoordinatorReplayReleaseV1 + if os.Getenv("MPC_WORKFLOW_V4_MIRROR") == "1" { + assurance.MirrorsPerAcceptedHead = 1 + } } initialized, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{ RootDir: ceremonyRoot, From 16f40d1ac2d504ea205d254f465651bccb95355c Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:40:58 +0900 Subject: [PATCH 09/53] Enforce checkpointed witness and beacon evidence before sealing --- internal/mpcceremony/checkpoint_v4.go | 28 ++-- .../checkpoint_v4_beacon_evidence.go | 128 ++++++++++++++++++ internal/mpcceremony/checkpoint_v4_files.go | 53 +++++++- .../mpcceremony/checkpoint_v4_files_test.go | 20 ++- .../testdata/workflowhelper/checkpoint_v4.go | 68 ++++++++++ .../testdata/workflowhelper/main.go | 1 + 6 files changed, 277 insertions(+), 21 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_beacon_evidence.go diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 8973d7c2..e5f31504 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -8,14 +8,16 @@ import ( ) const ( - CheckpointSchemaV4 = "proof-tool-mpc-checkpoint-v4" - StorageFirstWorkflowV2 = "storage-first-v2" - MaxCheckpointSequenceV4 = 16384 - CheckpointDeliveryRetired CheckpointTransitionKind = "delivery-retired" - CheckpointContributionRejected CheckpointTransitionKind = "contribution-rejected" - CheckpointDeliveryReallocated CheckpointTransitionKind = "delivery-reallocated" - CheckpointEnrollmentRecorded CheckpointTransitionKind = "enrollment-recorded" - CheckpointMirrorRecorded CheckpointTransitionKind = "mirror-recorded" + CheckpointSchemaV4 = "proof-tool-mpc-checkpoint-v4" + StorageFirstWorkflowV2 = "storage-first-v2" + MaxCheckpointSequenceV4 = 16384 + CheckpointDeliveryRetired CheckpointTransitionKind = "delivery-retired" + CheckpointContributionRejected CheckpointTransitionKind = "contribution-rejected" + CheckpointDeliveryReallocated CheckpointTransitionKind = "delivery-reallocated" + CheckpointEnrollmentRecorded CheckpointTransitionKind = "enrollment-recorded" + CheckpointMirrorRecorded CheckpointTransitionKind = "mirror-recorded" + CheckpointWitnessRecorded CheckpointTransitionKind = "witness-recorded" + CheckpointBeaconEvidenceRecorded CheckpointTransitionKind = "beacon-evidence-recorded" ) // CheckpointProgressV4 is the protocol projection used for guidance. It is not @@ -246,9 +248,13 @@ func (t CheckpointTransitionV4) Validate() error { if len(t.Evidence) != 1 { return errors.New("enrollment transition requires its disclosure artifact") } - case CheckpointMirrorRecorded: + case CheckpointMirrorRecorded, CheckpointWitnessRecorded: if len(t.Evidence) != 0 { - return errors.New("mirror edge adds only its signed receipt") + return errors.New("observer evidence edge adds only its signed receipt") + } + case CheckpointBeaconEvidenceRecorded: + if len(t.Evidence) < 2 || len(t.Evidence) > 16 { + return errors.New("beacon evidence edge requires two to sixteen raw responses") } case CheckpointPhase1Closed, CheckpointPhase2Closed: if len(t.Evidence) != 0 { @@ -382,7 +388,7 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { return errors.New("accepted artifact inventory must remain append-only") } t := next.Transition - if t.Kind == CheckpointEnrollmentRecorded || t.Kind == CheckpointMirrorRecorded { + if t.Kind == CheckpointEnrollmentRecorded || t.Kind == CheckpointMirrorRecorded || t.Kind == CheckpointWitnessRecorded || t.Kind == CheckpointBeaconEvidenceRecorded { if previous.Progress.FinalRelease != nil { return errors.New("cannot add assurance evidence after final release") } diff --git a/internal/mpcceremony/checkpoint_v4_beacon_evidence.go b/internal/mpcceremony/checkpoint_v4_beacon_evidence.go new file mode 100644 index 00000000..ad771548 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_beacon_evidence.go @@ -0,0 +1,128 @@ +package mpcceremony + +import ( + "errors" + "slices" +) + +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 +} + +func verifyCheckpointBeaconEvidenceV4(reader *checkpointReaderV4, d CeremonyDefinition, p CheckpointProgressV4, refs []SignedArtifactRefs, tx CheckpointTransitionV4) (map[Phase]bool, error) { + result := map[Phase]bool{} + key, err := identityPublicKey(d.Coordinator) + if err != nil { + return nil, err + } + for _, pair := range refs { + rb, sb, err := reader.pair(pair) + if err != nil { + return nil, err + } + var evidence MultiRelayBeaconEvidence + if err = VerifySignedRecord(rb, sb, &evidence, d.Coordinator.KeyID, key); err != nil { + return nil, err + } + closure, _, _, err := checkpointClosureV4(reader, d, p, evidence.Phase) + if err != nil { + return nil, err + } + if result[evidence.Phase] { + return nil, errors.New("duplicate beacon evidence for phase") + } + beaconRefs := p.Phase1Beacon + if evidence.Phase == Phase2 { + beaconRefs = p.Phase2Beacon + } + if beaconRefs == nil { + return nil, errors.New("beacon evidence requires the recorded phase beacon") + } + raw := map[string][]byte{} + expected := []ArtifactRef{} + for _, observation := range evidence.Observations { + b, err := reader.read(observation.RawResponse, maxDrandResponseBytes, true) + if err != nil { + return nil, err + } + raw[observation.RelayID] = b + expected = append(expected, observation.RawResponse) + } + if err = ValidateMultiRelayBeaconEvidence(d, closure, evidence, raw); err != nil { + return nil, err + } + if tx.Kind == CheckpointBeaconEvidenceRecorded && tx.Record != nil && pair == *tx.Record { + slices.SortFunc(expected, compareArtifactRefName) + if !slices.Equal(expected, tx.Evidence) { + return nil, errors.New("beacon evidence edge differs from its signed raw responses") + } + } + result[evidence.Phase] = true + } + return result, nil +} diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 57527269..638002c5 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -126,13 +126,15 @@ func (r *checkpointReaderV4) pair(refs SignedArtifactRefs) ([]byte, []byte, erro } type checkpointAncestryV4 struct { - head CheckpointV4 - outbound map[string]SignedArtifactRefs - receipts map[ContributionScope]SignedArtifactRefs - enrollments []SignedArtifactRefs - mirrors []SignedArtifactRefs - accepted map[ContributionScope]SignedArtifactRefs - count uint64 + head CheckpointV4 + outbound map[string]SignedArtifactRefs + receipts map[ContributionScope]SignedArtifactRefs + enrollments []SignedArtifactRefs + mirrors []SignedArtifactRefs + witnesses []SignedArtifactRefs + beaconEvidence []SignedArtifactRefs + accepted map[ContributionScope]SignedArtifactRefs + count uint64 } func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, definitionBytes, definitionSignature []byte, refs SignedArtifactRefs) (checkpointAncestryV4, error) { @@ -159,6 +161,12 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } } result.count++ + if current.Transition.Kind == CheckpointWitnessRecorded { + result.witnesses = append(result.witnesses, *current.Transition.Record) + } + if current.Transition.Kind == CheckpointBeaconEvidenceRecorded { + result.beaconEvidence = append(result.beaconEvidence, *current.Transition.Record) + } if current.Transition.Kind == CheckpointMirrorRecorded { result.mirrors = append(result.mirrors, *current.Transition.Record) } @@ -321,6 +329,37 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { return nil, errors.New("participant enrollment must be committed before outbound delivery") } } + if c.Transition.Kind == CheckpointWitnessRecorded || c.Transition.Kind == CheckpointBeaconEvidenceRecorded || c.Transition.Kind == CheckpointPhase1Sealed || c.Transition.Kind == CheckpointFinalCandidateRecorded { + witnessRefs := append([]SignedArtifactRefs{}, evidenceAncestry.witnesses...) + beaconRefs := append([]SignedArtifactRefs{}, evidenceAncestry.beaconEvidence...) + if c.Transition.Kind == CheckpointWitnessRecorded { + witnessRefs = append(witnessRefs, *c.Transition.Record) + } + if c.Transition.Kind == CheckpointBeaconEvidenceRecorded { + beaconRefs = append(beaconRefs, *c.Transition.Record) + } + witnessCounts, err := verifyCheckpointWitnessesV4(reader, d, previous.Progress, verifiedEnrollments, witnessRefs) + if err != nil { + return nil, err + } + beacons, err := verifyCheckpointBeaconEvidenceV4(reader, d, previous.Progress, beaconRefs, c.Transition) + if err != nil { + return nil, err + } + if c.Transition.Kind == CheckpointWitnessRecorded || c.Transition.Kind == CheckpointBeaconEvidenceRecorded { + 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 !beacons[phase] { + return nil, errors.New("verified multi-relay beacon evidence is required for this phase") + } + } if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, outbound, receipts); err != nil { return nil, err } diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 74fe1875..9be4b98e 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -32,8 +32,13 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { if output, err := build.CombinedOutput(); err != nil { t.Fatalf("build: %v\n%s", err, output) } - for _, mirrorMode := range []string{"0", "1"} { - t.Run("mirrors-"+mirrorMode, func(t *testing.T) { + for _, scenario := range []struct{ name, mirrorMode, extra, rejection string }{ + {name: "observers-disabled", mirrorMode: "0"}, + {name: "observers-enabled", mirrorMode: "1"}, + {name: "missing-witness", mirrorMode: "1", extra: "MPC_WORKFLOW_SKIP_WITNESS=1", rejection: "signed witness minimum"}, + {name: "missing-beacon-evidence", mirrorMode: "0", extra: "MPC_WORKFLOW_SKIP_BEACON_EVIDENCE=1", rejection: "multi-relay beacon evidence is required"}, + } { + t.Run(scenario.name, func(t *testing.T) { run := exec.Command(helper, filepath.Join(t.TempDir(), "ceremony-run")) run.Dir = repo for _, entry := range os.Environ() { @@ -42,8 +47,17 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { } 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="+mirrorMode) + 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) } diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index ff1805bd..f9f7cd70 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -416,6 +416,47 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com 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 } @@ -445,6 +486,33 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if err = commit(); err != nil { return err } + beaconEvidence := m.MultiRelayBeaconEvidence{Schema: m.MultiRelayBeaconEvidenceSchema, CeremonyID: d.CeremonyID, Phase: m.Phase1, CloseID: closure.CloseID, BeaconRound: 42, Provider: d.BeaconPolicy.Provider, Network: d.BeaconPolicy.Network, CoordinatorID: d.Coordinator.ID, CoordinatorKeyID: d.Coordinator.KeyID, RecordedAt: "2023-08-23T15:11:30Z"} + rawRefs := []m.ArtifactRef{} + for _, id := range []string{"fixture-a", "fixture-b"} { + name := "phase1/beacon-evidence/" + id + ".json" + if err = os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0700); err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, name), []byte(quicknetRound42), 0600); err != nil { + return err + } + rr, err := ref(name) + if err != nil { + return err + } + rawRefs = append(rawRefs, rr) + beaconEvidence.Observations = append(beaconEvidence.Observations, m.RelayObservation{RelayID: id, OperatorID: id, EndpointSHA256: m.NewDigest([]byte(id)).SHA256, RawResponse: rr, RetrievedAt: "2023-08-23T15:11:30Z", VerifiedRandomness: beacon.Beacon.RandomnessHex}) + } + beRefs, err := writePair("phase1/beacon-evidence/record", beaconEvidence, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + if os.Getenv("MPC_WORKFLOW_SKIP_BEACON_EVIDENCE") != "1" { + next(m.CheckpointTransitionV4{Kind: m.CheckpointBeaconEvidenceRecorded, Record: &beRefs, Evidence: sorted(rawRefs)}) + 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 diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 297cf211..fb0793ff 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -234,6 +234,7 @@ func run(outputRoot, operationalEvidenceHelper string) error { releaseVerification = mpcceremony.CoordinatorReplayReleaseV1 if os.Getenv("MPC_WORKFLOW_V4_MIRROR") == "1" { assurance.MirrorsPerAcceptedHead = 1 + assurance.PublicWitnessesPerPhase = 1 } } initialized, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{ From a6865298120fa1596e1593666e9b0f41744473c6 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 03:59:10 +0900 Subject: [PATCH 10/53] Bind V4 final candidates to complete coordinator replay --- docs/ceremony-schema-compatibility.md | 9 +- internal/mpcceremony/checkpoint_v4.go | 35 +- internal/mpcceremony/checkpoint_v4_files.go | 2 + .../mpcceremony/checkpoint_v4_files_test.go | 2 +- internal/mpcceremony/checkpoint_v4_final.go | 61 +++ .../mpcceremony/checkpoint_v4_final_test.go | 51 +++ internal/mpcceremony/checkpoint_v4_test.go | 16 + .../testdata/workflowhelper/checkpoint_v4.go | 2 +- .../workflowhelper/checkpoint_v4_final.go | 388 ++++++++++++++++++ .../testdata/workflowhelper/main.go | 19 +- 10 files changed, 570 insertions(+), 15 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_final.go create mode 100644 internal/mpcceremony/checkpoint_v4_final_test.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index bd6f32aa..33c08e06 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -27,12 +27,13 @@ check by changing the meaning of V3 or a generic "current schema" constant. ## New-version implementation gate Current draft: opt-in V4 construction, structural checkpoints and real-artifact -verification through Phase 2 initialization exist in the library. The Linux -integration test uses a real tiny contribution, signed custody records, genuine -historical drand response, Phase 1 replay/sealing and Phase 2 genesis. Its +verification through final-candidate recording exist in the library. The Linux +integration test uses real tiny contributions in both phases, signed custody +records, genuine historical drand responses and complete coordinator replay. +Final-candidate authoring binds the exact executable and closed file inventory. Its environment and cleanup claims are test fixtures, not physical assurance. Normal initialization still emits V3. Do not release this slice alone: remaining -evidence gates, final authoring and the new release/decision verification path +audit/governance evidence, final-release authoring and the new release/decision verification path are incomplete. These tests are not the normal CLI journey or a full ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index e5f31504..af743b67 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -35,13 +35,21 @@ type CheckpointProgressV4 struct { } 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"` - Record *SignedArtifactRefs `json:"record,omitempty"` - Evidence []ArtifactRef `json:"evidence"` - Contribution *CandidateInventory `json:"contribution,omitempty"` + Kind CheckpointTransitionKind `json:"kind"` + Scope *ContributionScope `json:"scope,omitempty"` + AttemptID string `json:"attempt_id,omitempty"` + NextAttemptID string `json:"next_attempt_id,omitempty"` + Record *SignedArtifactRefs `json:"record,omitempty"` + Evidence []ArtifactRef `json:"evidence"` + Contribution *CandidateInventory `json:"contribution,omitempty"` + ReplayVerification *CheckpointReplayVerificationV4 `json:"replay_verification,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, @@ -183,6 +191,16 @@ func (c CheckpointV4) Validate() error { } func (t CheckpointTransitionV4) Validate() error { + 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 } @@ -322,6 +340,9 @@ func validateCheckpointDefinitionBindingV4(d CeremonyDefinition, definitionBytes 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 diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 638002c5..bfe11ba0 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -389,6 +389,8 @@ func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *Truste 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) default: return errors.New("real-artifact authoring for this v4 transition is not implemented yet") } diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 9be4b98e..1ad7126d 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -61,7 +61,7 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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), "closure, drand, seal, phase2 genesis") { + 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) } }) diff --git a/internal/mpcceremony/checkpoint_v4_final.go b/internal/mpcceremony/checkpoint_v4_final.go new file mode 100644 index 00000000..145b3c9b --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_final.go @@ -0,0 +1,61 @@ +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") + } + running, err := RunningSoftwareBindingForMode(trusted.Definition.Software.ProofToolVersion, trusted.Definition.Mode) + if err != nil { + return err + } + if t.ReplayVerification == nil || t.ReplayVerification.ToolBinary != running.ToolBinary { + return errors.New("final candidate replay claim must identify the actual approved executable") + } + 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_test.go b/internal/mpcceremony/checkpoint_v4_test.go index 5c7d1253..44c5cd84 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -159,6 +159,22 @@ func TestCheckpointV4FullStructuralLifecycle(t *testing.T) { 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 CheckpointFinalReleaseRecorded: next.Progress.FinalRelease = &record } diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index f9f7cd70..1b547579 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -561,5 +561,5 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com return err } fmt.Println("V4 real phase1 turn passed: initial, outbound, retirement, reallocation, receipt, contribution, cleanup, full replay, exact acceptance, corruption rejected, closure, drand, seal, phase2 genesis") - return nil + 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..20d2a5de --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -0,0 +1,388 @@ +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)} + handoff, err := m.NewTransferHandoff(d, m.Phase2, 1, scope.ParentHeadID, []m.ArtifactRef{c.Progress.Phase2.HeadPayload}, d.Coordinator, p, "2023-08-23T15:11:30.1Z", "2023-08-23T16:11:30.1Z") + if err != nil { + return err + } + hr, err := writePair("custody/phase2-outbound", handoff, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + const receiptAttempt = "dddddddddddddddddddddddddddddddd" + const candidateAttempt = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2OutboundPublished, Scope: &scope, AttemptID: receiptAttempt, Record: &hr, Evidence: []m.ArtifactRef{}}) + c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionReceipt, receiptAttempt) + if err != nil { + return err + } + if err = commit(); err != nil { + return err + } + hb, err := os.ReadFile(path(hr.Record)) + if err != nil { + return err + } + receipt, err := m.NewTransferReceipt(handoff, hb, m.ReceiptReceiver, "2023-08-23T15:11:30.2Z") + if err != nil { + return err + } + rr, err := writePair("custody/phase2-receipt", receipt, p.KeyID, participant) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2ReceiptAccepted, Scope: &scope, AttemptID: receiptAttempt, NextAttemptID: candidateAttempt, Record: &rr, Evidence: []m.ArtifactRef{}}) + c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, receiptAttempt, m.DeliveryAccepted, nil) + if err != nil { + return err + } + c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionCandidate, candidateAttempt) + if err != nil { + return err + } + if err = commit(); err != nil { + return err + } + 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.CreateContributionCandidate(m.ContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase2, Transcript: paths, Phase1SealPath: path(seal.Record), Phase1SealSignaturePath: path(seal.Signature), ParticipantID: p.ID, 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)}) + } + rh, err := m.NewTransferHandoff(d, m.Phase2, 1, scope.ParentHeadID, files, p, d.Coordinator, "2023-08-23T15:11:30.41Z", "2023-08-23T16:11:30.41Z") + if err != nil { + return err + } + rhr, err := writePair("custody/phase2-return-handoff", rh, p.KeyID, participant) + if err != nil { + return err + } + b, err := os.ReadFile(path(rhr.Record)) + if err != nil { + return err + } + returnReceipt, err := m.NewTransferReceipt(rh, b, m.ReceiptReceiver, "2023-08-23T15:11:30.42Z") + if err != nil { + return err + } + rrr, err := writePair("custody/phase2-return-receipt", returnReceipt, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + 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} + returns := []m.ArtifactRef{} + for i, pair := range []m.SignedArtifactRefs{rhr, rrr} { + base := "return-handoff" + if i == 1 { + base = "return-receipt" + } + for j, r := range []m.ArtifactRef{pair.Record, pair.Signature} { + ext := ".json" + if j == 1 { + ext = ".sig" + } + b, err := os.ReadFile(path(r)) + if err != nil { + return err + } + name := "phase2/contributions/0001/" + base + ext + if err = os.WriteFile(filepath.Join(root, name), b, 0600); err != nil { + return err + } + returns = append(returns, m.ArtifactRef{Name: name, Digest: m.NewDigest(b)}) + } + } + files = append(files, returns[:2]...) + 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(append([]m.ArtifactRef{}, files...), returns[2:]...) + 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 + } + be := m.MultiRelayBeaconEvidence{Schema: m.MultiRelayBeaconEvidenceSchema, CeremonyID: d.CeremonyID, Phase: m.Phase2, CloseID: closure.CloseID, BeaconRound: 43, Provider: d.BeaconPolicy.Provider, Network: d.BeaconPolicy.Network, CoordinatorID: d.Coordinator.ID, CoordinatorKeyID: d.Coordinator.KeyID, RecordedAt: "2023-08-23T15:11:33Z"} + raws := []m.ArtifactRef{} + for _, id := range []string{"fixture-a", "fixture-b"} { + name := "phase2/beacon-evidence/" + id + ".json" + if err = os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0700); err != nil { + return err + } + if err = os.WriteFile(filepath.Join(root, name), []byte(quicknetRound43), 0600); err != nil { + return err + } + r, err := ref(name) + if err != nil { + return err + } + raws = append(raws, r) + be.Observations = append(be.Observations, m.RelayObservation{RelayID: id, OperatorID: id, EndpointSHA256: m.NewDigest([]byte(id)).SHA256, RawResponse: r, RetrievedAt: "2023-08-23T15:11:33Z", VerifiedRandomness: beacon.Beacon.RandomnessHex}) + } + ber, err := writePair("phase2/beacon-evidence/record", be, d.Coordinator.KeyID, coordinator) + if err != nil { + return err + } + next(m.CheckpointTransitionV4{Kind: m.CheckpointBeaconEvidenceRecorded, Record: &ber, Evidence: sorted(raws)}) + 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, custody, optional observers, second drand round, coordinator full replay, exact final inventory") + return nil +} diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index fb0793ff..e92a0bb2 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" ) @@ -218,7 +219,7 @@ func run(outputRoot, operationalEvidenceHelper string) error { if os.Getenv("MPC_WORKFLOW_PHASE1_ONE") == "1" || checkpointPhase2One || checkpointV4 { phaseMinimum = 1 } - if checkpointPhase2One { + if checkpointPhase2One || checkpointV4 { phase2Minimum = 1 } auditors := []mpcceremony.Identity{} @@ -1179,7 +1180,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 From 89b582f84325e4e17f7032ad5ea54eeabace3390 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:08:00 +0900 Subject: [PATCH 11/53] Collect checkpointed audits without weakening release quorum --- internal/mpcceremony/audit.go | 64 +++++++++++++----- internal/mpcceremony/checkpoint_v4.go | 10 ++- internal/mpcceremony/checkpoint_v4_audits.go | 50 ++++++++++++++ .../mpcceremony/checkpoint_v4_audits_test.go | 65 +++++++++++++++++++ internal/mpcceremony/checkpoint_v4_files.go | 16 +++++ .../mpcceremony/checkpoint_v4_files_test.go | 4 ++ .../workflowhelper/checkpoint_v4_final.go | 63 ++++++++++++++++++ .../testdata/workflowhelper/main.go | 6 +- 8 files changed, 257 insertions(+), 21 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_audits.go create mode 100644 internal/mpcceremony/checkpoint_v4_audits_test.go diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 49b41ff1..9654526a 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -1090,16 +1090,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.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 @@ -1121,14 +1162,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) @@ -1175,11 +1209,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 } diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index af743b67..7fc55840 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -18,6 +18,7 @@ const ( CheckpointMirrorRecorded CheckpointTransitionKind = "mirror-recorded" CheckpointWitnessRecorded CheckpointTransitionKind = "witness-recorded" CheckpointBeaconEvidenceRecorded CheckpointTransitionKind = "beacon-evidence-recorded" + CheckpointAuditRecorded CheckpointTransitionKind = "audit-recorded" ) // CheckpointProgressV4 is the protocol projection used for guidance. It is not @@ -266,9 +267,9 @@ func (t CheckpointTransitionV4) Validate() error { if len(t.Evidence) != 1 { return errors.New("enrollment transition requires its disclosure artifact") } - case CheckpointMirrorRecorded, CheckpointWitnessRecorded: + case CheckpointMirrorRecorded, CheckpointWitnessRecorded, CheckpointAuditRecorded: if len(t.Evidence) != 0 { - return errors.New("observer evidence edge adds only its signed receipt") + return errors.New("assurance evidence edge adds only its signed record") } case CheckpointBeaconEvidenceRecorded: if len(t.Evidence) < 2 || len(t.Evidence) > 16 { @@ -409,10 +410,13 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { return errors.New("accepted artifact inventory must remain append-only") } t := next.Transition - if t.Kind == CheckpointEnrollmentRecorded || t.Kind == CheckpointMirrorRecorded || t.Kind == CheckpointWitnessRecorded || t.Kind == CheckpointBeaconEvidenceRecorded { + if t.Kind == CheckpointEnrollmentRecorded || t.Kind == CheckpointMirrorRecorded || t.Kind == CheckpointWitnessRecorded || t.Kind == CheckpointBeaconEvidenceRecorded || t.Kind == CheckpointAuditRecorded { if previous.Progress.FinalRelease != nil { return errors.New("cannot add assurance evidence after final release") } + 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") } 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_files.go b/internal/mpcceremony/checkpoint_v4_files.go index bfe11ba0..7e132282 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -133,6 +133,7 @@ type checkpointAncestryV4 struct { mirrors []SignedArtifactRefs witnesses []SignedArtifactRefs beaconEvidence []SignedArtifactRefs + audits []SignedArtifactRefs accepted map[ContributionScope]SignedArtifactRefs count uint64 } @@ -161,6 +162,9 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } } result.count++ + 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) } @@ -302,6 +306,18 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { } 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 { diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 1ad7126d..28176795 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -35,6 +35,7 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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"}, {name: "missing-beacon-evidence", mirrorMode: "0", extra: "MPC_WORKFLOW_SKIP_BEACON_EVIDENCE=1", rejection: "multi-relay beacon evidence is required"}, } { @@ -64,6 +65,9 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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) + } }) } } diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go index 20d2a5de..59d193ba 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -384,5 +384,68 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co return fmt.Errorf("final candidate accepted an extra file") } fmt.Println("V4 phase2 and final candidate passed: real contribution, custody, 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") + } return nil } diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index e92a0bb2..98110add 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -178,7 +178,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 @@ -233,6 +233,10 @@ func run(outputRoot, operationalEvidenceHelper string) error { 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 From 2bfae6643ccfa4dc7f9dac2b539675d06ff52871 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:23:31 +0900 Subject: [PATCH 12/53] Derive operational evidence from authenticated V4 history --- docs/ceremony-schema-compatibility.md | 9 +- internal/mpcceremony/checkpoint_v4_bundle.go | 208 ++++++++++++++++++ .../mpcceremony/checkpoint_v4_bundle_test.go | 23 ++ internal/mpcceremony/checkpoint_v4_files.go | 34 ++- .../mpcceremony/checkpoint_v4_files_test.go | 3 + .../workflowhelper/checkpoint_v4_final.go | 127 +++++++++++ 6 files changed, 392 insertions(+), 12 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_bundle.go create mode 100644 internal/mpcceremony/checkpoint_v4_bundle_test.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 33c08e06..e3e2b4ea 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -32,8 +32,15 @@ integration test uses real tiny contributions in both phases, signed custody records, genuine historical drand responses and complete coordinator replay. Final-candidate authoring binds the exact executable and closed file inventory. Its environment and cleanup claims are test fixtures, not physical assurance. +Audit collection verifies each signed record and keeps the full release quorum. +Operational bundle preparation derives the unchanged v3 bundle only from exact +checkpointed records, including all required enrollments and per-turn custody. +It runs the existing bundle verifier without signing or repeating mathematics. +Its source-checkpoint metadata must be rebound at final release; it is not an +extra field in the signed legacy bundle. Linux tests reject missing enrollments, +loose uncommitted records and corrupted retained evidence. Normal initialization still emits V3. Do not release this slice alone: remaining -audit/governance evidence, final-release authoring and the new release/decision verification path +governance evidence, final-release authoring and the new release/decision verification path are incomplete. These tests are not the normal CLI journey or a full ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed diff --git a/internal/mpcceremony/checkpoint_v4_bundle.go b/internal/mpcceremony/checkpoint_v4_bundle.go new file mode 100644 index 00000000..b799af3c --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_bundle.go @@ -0,0 +1,208 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path" + "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 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 { + 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 + } + if _, err = verifyCheckpointBeaconEvidenceV4(reader, d, p, a.beaconEvidence, CheckpointTransitionV4{}); 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 _, refs := range a.beaconEvidence { + var r MultiRelayBeaconEvidence + if err = read(refs, &r); err != nil { + return OperationalEvidenceBundle{}, err + } + beacons[r.Phase] = refs + for _, ob := range r.Observations { + raws[r.Phase] = append(raws[r.Phase], ob.RawResponse) + } + } + bundle := OperationalEvidenceBundle{Schema: OperationalEvidenceBundleSchema, 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 _, 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]), MultiRelayBeaconEvidence: beacons[phase], RawBeaconResponses: append([]ArtifactRef{}, raws[phase]...)} + slices.SortFunc(pe.RawBeaconResponses, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + 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++ + receipt, ok := a.receipts[scope] + if !ok { + return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d lacks its committed input receipt", phase, record.Index) + } + var received TransferReceipt + if err = read(receipt, &received); err != nil { + return OperationalEvidenceBundle{}, err + } + handoff, ok := a.outbound[received.HandoffSHA256] + if !ok { + return OperationalEvidenceBundle{}, errors.New("accepted receipt has no committed original handoff") + } + files := map[string]ArtifactRef{} + for _, r := range tx.Evidence { + files[r.Name] = r + } + dir := path.Dir(record.Attestation.Name) + returnHandoff := SignedArtifactRefs{Record: files[dir+"/return-handoff.json"], Signature: files[dir+"/return-handoff.sig"]} + returnReceipt := SignedArtifactRefs{Record: files[dir+"/return-receipt.json"], Signature: files[dir+"/return-receipt.sig"]} + if err = returnHandoff.Validate(); err != nil { + return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d missing committed return handoff: %w", phase, record.Index, err) + } + if err = returnReceipt.Validate(); err != nil { + return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d missing committed return receipt: %w", phase, record.Index, err) + } + pe.AcceptedHeads = append(pe.AcceptedHeads, AcceptedHeadOperationalEvidence{Index: record.Index, PredecessorHeadID: record.PreviousRecordID, AcceptedHeadID: record.RecordID, OutboundHandoff: handoff, OutboundReceipt: receipt, ReturnHandoff: returnHandoff, ReturnReceipt: returnReceipt, 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..aa63ee5e --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_bundle_test.go @@ -0,0 +1,23 @@ +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{}}, + } { + 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_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 7e132282..c8073718 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -126,20 +126,23 @@ func (r *checkpointReaderV4) pair(refs SignedArtifactRefs) ([]byte, []byte, erro } type checkpointAncestryV4 struct { - head CheckpointV4 - outbound map[string]SignedArtifactRefs - receipts map[ContributionScope]SignedArtifactRefs - enrollments []SignedArtifactRefs - mirrors []SignedArtifactRefs - witnesses []SignedArtifactRefs - beaconEvidence []SignedArtifactRefs - audits []SignedArtifactRefs - accepted map[ContributionScope]SignedArtifactRefs - count uint64 + head CheckpointV4 + outbound map[string]SignedArtifactRefs + receipts map[ContributionScope]SignedArtifactRefs + enrollments []SignedArtifactRefs + mirrors []SignedArtifactRefs + witnesses []SignedArtifactRefs + beaconEvidence []SignedArtifactRefs + audits []SignedArtifactRefs + accepted map[ContributionScope]SignedArtifactRefs + acceptedTransitions map[ContributionScope]CheckpointTransitionV4 + checkpoints []SignedArtifactRefs // newest to oldest, including head + finalCandidateCheckpoint *SignedArtifactRefs + count uint64 } func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, definitionBytes, definitionSignature []byte, refs SignedArtifactRefs) (checkpointAncestryV4, error) { - result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}, accepted: map[ContributionScope]SignedArtifactRefs{}} + result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}, accepted: map[ContributionScope]SignedArtifactRefs{}, acceptedTransitions: map[ContributionScope]CheckpointTransitionV4{}} var child *CheckpointV4 for { if result.count > MaxCheckpointSequenceV4 { @@ -162,6 +165,11 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } } result.count++ + result.checkpoints = append(result.checkpoints, refs) + if current.Transition.Kind == CheckpointFinalCandidateRecorded { + pair := refs + result.finalCandidateCheckpoint = &pair + } if current.Transition.Kind == CheckpointAuditRecorded { result.audits = append(result.audits, *current.Transition.Record) } @@ -175,7 +183,11 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, 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) diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 28176795..47514650 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -68,6 +68,9 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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) + } }) } } diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go index 59d193ba..43e2bf69 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -447,5 +447,132 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co } 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 + } + } + 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 + } + 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].ReturnReceipt.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 + } + fmt.Println("V4 operational bundle passed: deterministic checkpoint-only assembly, all roster enrollments, original bundle verifier, corruption rejected") return nil } From 4ae603089aa68881719e5940f63d077fef0d040d Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 04:40:48 +0900 Subject: [PATCH 13/53] Preserve V4 incidents and enforce terminal abort and restart --- docs/ceremony-schema-compatibility.md | 8 +- internal/mpcceremony/checkpoint_v4.go | 72 ++++++- internal/mpcceremony/checkpoint_v4_bundle.go | 9 +- .../mpcceremony/checkpoint_v4_bundle_test.go | 1 + internal/mpcceremony/checkpoint_v4_files.go | 25 +++ .../mpcceremony/checkpoint_v4_files_test.go | 3 + .../mpcceremony/checkpoint_v4_governance.go | 140 +++++++++++++ .../checkpoint_v4_governance_test.go | 191 ++++++++++++++++++ internal/mpcceremony/checkpoint_v4_test.go | 16 ++ .../testdata/workflowhelper/checkpoint_v4.go | 11 +- .../workflowhelper/checkpoint_v4_final.go | 76 +++++++ 11 files changed, 548 insertions(+), 4 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_governance.go create mode 100644 internal/mpcceremony/checkpoint_v4_governance_test.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index e3e2b4ea..08861f23 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -39,8 +39,14 @@ It runs the existing bundle verifier without signing or repeating mathematics. Its source-checkpoint metadata must be rebound at final release; it is not an extra field in the signed legacy bundle. Linux tests reject missing enrollments, loose uncommitted records and corrupted retained evidence. +V4 governance uses the existing signed records with stricter explicit +coordinator/current-head checks. Informational incidents enter the bundle; +abort/restart are terminal and cannot prepare a release bundle. An authorized +restart points to an exact signed V4 definition; the new definition alone does +not establish lineage. Historical inspection rechecks these governance edges +against their exact predecessor, not just the record signature. Normal initialization still emits V3. Do not release this slice alone: remaining -governance evidence, final-release authoring and the new release/decision verification path +final-release authoring and the new release/decision verification path are incomplete. These tests are not the normal CLI journey or a full ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 7fc55840..c0181fec 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -19,6 +19,9 @@ const ( CheckpointWitnessRecorded CheckpointTransitionKind = "witness-recorded" CheckpointBeaconEvidenceRecorded CheckpointTransitionKind = "beacon-evidence-recorded" CheckpointAuditRecorded CheckpointTransitionKind = "audit-recorded" + CheckpointIncidentRecorded CheckpointTransitionKind = "incident-recorded" + CheckpointAborted CheckpointTransitionKind = "ceremony-aborted" + CheckpointRestarted CheckpointTransitionKind = "ceremony-restarted" ) // CheckpointProgressV4 is the protocol projection used for guidance. It is not @@ -33,6 +36,15 @@ type CheckpointProgressV4 struct { Phase2Beacon *SignedArtifactRefs `json:"phase2_beacon,omitempty"` FinalCandidate *SignedArtifactRefs `json:"final_candidate,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 { @@ -44,6 +56,7 @@ type CheckpointTransitionV4 struct { 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 @@ -168,6 +181,13 @@ func (c CheckpointV4) Validate() error { } 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") @@ -178,7 +198,11 @@ func (c CheckpointV4) Validate() error { return errors.New("delivery belongs to another ceremony") } if slot.Status == DeliveryAllocated { - if err := c.Progress.currentTurn(slot.Scope); err != nil { + // 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 } } @@ -192,6 +216,16 @@ func (c CheckpointV4) Validate() error { } 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") @@ -263,6 +297,14 @@ func (t CheckpointTransitionV4) Validate() error { 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") @@ -367,6 +409,9 @@ func validateCheckpointDefinitionBindingV4(d CeremonyDefinition, definitionBytes } 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 { @@ -410,6 +455,31 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { 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") + } + 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 == CheckpointBeaconEvidenceRecorded || t.Kind == CheckpointAuditRecorded { if previous.Progress.FinalRelease != nil { return errors.New("cannot add assurance evidence after final release") diff --git a/internal/mpcceremony/checkpoint_v4_bundle.go b/internal/mpcceremony/checkpoint_v4_bundle.go index b799af3c..ec66bb1d 100644 --- a/internal/mpcceremony/checkpoint_v4_bundle.go +++ b/internal/mpcceremony/checkpoint_v4_bundle.go @@ -80,7 +80,7 @@ func sortedSignedRefsV4(refs []SignedArtifactRefs) []SignedArtifactRefs { 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 { + 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) @@ -138,6 +138,13 @@ func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCerem } bundle := OperationalEvidenceBundle{Schema: OperationalEvidenceBundleSchema, 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 diff --git a/internal/mpcceremony/checkpoint_v4_bundle_test.go b/internal/mpcceremony/checkpoint_v4_bundle_test.go index aa63ee5e..c0de1bc8 100644 --- a/internal/mpcceremony/checkpoint_v4_bundle_test.go +++ b/internal/mpcceremony/checkpoint_v4_bundle_test.go @@ -10,6 +10,7 @@ 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. diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index c8073718..5b5d5e4f 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -134,6 +134,7 @@ type checkpointAncestryV4 struct { witnesses []SignedArtifactRefs beaconEvidence []SignedArtifactRefs audits []SignedArtifactRefs + incidents []CheckpointTransitionV4 accepted map[ContributionScope]SignedArtifactRefs acceptedTransitions map[ContributionScope]CheckpointTransitionV4 checkpoints []SignedArtifactRefs // newest to oldest, including head @@ -163,9 +164,21 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, 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++ 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 @@ -209,6 +222,7 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, // 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) { trusted, err := LoadSignedDefinition(trust) @@ -294,6 +308,17 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { } } // 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 diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 47514650..7b5e9cca 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -71,6 +71,9 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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) + } }) } } 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..b1ab37f8 --- /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], turn[3]} { + 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, CheckpointPhase1OutboundPublished, 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 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 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_test.go b/internal/mpcceremony/checkpoint_v4_test.go index 44c5cd84..73374193 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -190,6 +190,22 @@ func TestCheckpointV4FullStructuralLifecycle(t *testing.T) { 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) { diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 1b547579..0e85b77b 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -7,6 +7,7 @@ import ( "os" "path/filepath" "runtime" + "slices" "sort" "strings" "time" @@ -95,7 +96,15 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com refs = append(refs, tx.Record.Record, tx.Record.Signature) } refs = append(refs, tx.Evidence...) - c.AcceptedArtifacts = sorted(refs) + // 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 diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go index 43e2bf69..99c5a01c 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -509,6 +509,45 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co 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 @@ -517,6 +556,9 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co 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 @@ -574,5 +616,39 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co return err } fmt.Println("V4 operational bundle passed: deterministic checkpoint-only assembly, all roster enrollments, original bundle verifier, corruption rejected") + 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 } From 3351b08a3e260bf0e37237bfcfdb2cb9ed5f68b1 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:00:02 +0900 Subject: [PATCH 14/53] Verify exact V4 final review without duplicate contribution replay --- docs/ceremony-schema-compatibility.md | 5 + internal/mpcceremony/audit.go | 48 ++-- .../mpcceremony/checkpoint_v4_files_test.go | 9 +- .../checkpoint_v4_public_outputs.go | 49 ++++ internal/mpcceremony/checkpoint_v4_review.go | 255 ++++++++++++++++++ .../mpcceremony/checkpoint_v4_review_test.go | 81 ++++++ .../workflowhelper/checkpoint_v4_final.go | 3 + .../workflowhelper/checkpoint_v4_review.go | 146 ++++++++++ 8 files changed, 574 insertions(+), 22 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_public_outputs.go create mode 100644 internal/mpcceremony/checkpoint_v4_review.go create mode 100644 internal/mpcceremony/checkpoint_v4_review_test.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 08861f23..386af2cf 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -45,6 +45,11 @@ abort/restart are terminal and cannot prepare a release bundle. An authorized restart points to an exact signed V4 definition; the new definition alone does not establish lineage. Historical inspection rechecks these governance edges against their exact predecessor, not just the record signature. +The read-only V4 final-review API now binds an exact review checkpoint, +coordinator replay checkpoint, closed candidate inventory, signed/rederived +bundle and checkpoint-derived audit quorum. It authenticates lifecycle records, +verifies key exports and the public proof, but does not replay contributions or +regenerate keys. Existing V1–V3 replay/verification gates are unchanged. Normal initialization still emits V3. Do not release this slice alone: remaining final-release authoring and the new release/decision verification path are incomplete. These tests are not the normal CLI journey or a full ceremony. diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 9654526a..9c2a8f54 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,18 +409,6 @@ 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 } @@ -783,14 +796,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( diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 7b5e9cca..78b8a3be 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -40,7 +40,8 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { {name: "missing-beacon-evidence", mirrorMode: "0", extra: "MPC_WORKFLOW_SKIP_BEACON_EVIDENCE=1", rejection: "multi-relay beacon evidence is required"}, } { t.Run(scenario.name, func(t *testing.T) { - run := exec.Command(helper, filepath.Join(t.TempDir(), "ceremony-run")) + 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_") { @@ -74,6 +75,12 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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 scenario.name == "observers-disabled" { + testV4CoherentInvalidPublicProof(t, filepath.Join(outputRoot, "ceremony")) + } }) } } diff --git a/internal/mpcceremony/checkpoint_v4_public_outputs.go b/internal/mpcceremony/checkpoint_v4_public_outputs.go new file mode 100644 index 00000000..68393f7b --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_public_outputs.go @@ -0,0 +1,49 @@ +package mpcceremony + +import ( + "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 + if _, err := readCanonicalFile(filepath.Join(dir, candidate.VerificationReport.Name), &report); err != nil { + return err + } + if err := validateCandidatePublicReport(report, cardano, format); err != nil { + return err + } + if _, _, _, err := loadAndVerifyPublicEvidence(filepath.Join(dir, candidate.PublicEvidence.Name), d.CeremonyID, vk, cardano, candidate.CardanoVerifyingKey); err != nil { + return fmt.Errorf("V4 public proof verification: %w", err) + } + return nil +} diff --git a/internal/mpcceremony/checkpoint_v4_review.go b/internal/mpcceremony/checkpoint_v4_review.go new file mode 100644 index 00000000..2e874171 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_review.go @@ -0,0 +1,255 @@ +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"` + 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 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) { + 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 reader.root.Close() + 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 { + return ReleaseReviewV4{}, errors.New("review requires an unreleased, unterminated final candidate") + } + 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 := int64(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") + candidate, candidateRef, err := verifyCandidate(d, definitionRef, candidateDir) + if err != nil { + return ReleaseReviewV4{}, err + } + candidate, inventory, err := verifyCandidateClosedTree(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") + } + if _, err := verifyReleaseOperationalEvidence(d, trusted.CoordinatorPublicKey, candidate, reader.path, filepath.Join(reader.path, bundleRefs.Record.Name), filepath.Join(reader.path, bundleRefs.Signature.Name), releasedAt); 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)} + if err := result.Validate(); 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_test.go b/internal/mpcceremony/checkpoint_v4_review_test.go new file mode 100644 index 00000000..53b8fcb8 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_review_test.go @@ -0,0 +1,81 @@ +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) + } + 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/testdata/workflowhelper/checkpoint_v4_final.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go index 99c5a01c..9557178c 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -616,6 +616,9 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co 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 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..ae648a18 --- /dev/null +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -0,0 +1,146 @@ +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") + } + 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(), "signed bundle does not match the exact review checkpoint") || result.CeremonyID != "" { + return fmt.Errorf("stale bundle review: %v", err) + } + fmt.Println("V4 final review passed: no contribution replay input, deterministic exact binding, stale bundle and changed files rejected") + return nil +} From 9bc7b898c10f40bda59ab8b6225b983613740bb9 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:05:01 +0900 Subject: [PATCH 15/53] Bind V4 public verification to the exact bytes read --- .../checkpoint_v4_public_outputs.go | 14 ++++++++-- .../mpcceremony/checkpoint_v4_review_test.go | 28 +++++++++++++++++++ 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4_public_outputs.go b/internal/mpcceremony/checkpoint_v4_public_outputs.go index 68393f7b..670b4507 100644 --- a/internal/mpcceremony/checkpoint_v4_public_outputs.go +++ b/internal/mpcceremony/checkpoint_v4_public_outputs.go @@ -1,6 +1,7 @@ package mpcceremony import ( + "errors" "fmt" "path/filepath" @@ -36,14 +37,23 @@ func verifyCandidatePublicOutputsV4(d CeremonyDefinition, candidate CandidateMet return err } var report VerificationReport - if _, err := readCanonicalFile(filepath.Join(dir, candidate.VerificationReport.Name), &report); err != nil { + 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 } - if _, _, _, err := loadAndVerifyPublicEvidence(filepath.Join(dir, candidate.PublicEvidence.Name), d.CeremonyID, vk, cardano, candidate.CardanoVerifyingKey); err != nil { + _, 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_review_test.go b/internal/mpcceremony/checkpoint_v4_review_test.go index 53b8fcb8..d92aa42c 100644 --- a/internal/mpcceremony/checkpoint_v4_review_test.go +++ b/internal/mpcceremony/checkpoint_v4_review_test.go @@ -28,6 +28,34 @@ func testV4CoherentInvalidPublicProof(t *testing.T, root string) { 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) From badfa74cc7e8e322b7146b34d9f2007418a3dedb Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:14:15 +0900 Subject: [PATCH 16/53] Derive V4 review dependencies without historical payload copies --- docs/ceremony-schema-compatibility.md | 8 ++ internal/mpcceremony/checkpoint_v4_review.go | 14 +++- .../mpcceremony/checkpoint_v4_review_files.go | 62 +++++++++++++++ .../checkpoint_v4_review_files_test.go | 25 ++++++ internal/mpcceremony/operational_bundle.go | 27 ++++--- .../mpcceremony/operational_bundle_test.go | 25 ++++++ .../workflowhelper/checkpoint_v4_review.go | 78 +++++++++++++++++++ 7 files changed, 229 insertions(+), 10 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_review_files.go create mode 100644 internal/mpcceremony/checkpoint_v4_review_files_test.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 386af2cf..96710714 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -50,6 +50,14 @@ coordinator replay checkpoint, closed candidate inventory, signed/rederived bundle and checkpoint-derived audit quorum. It authenticates lifecycle records, verifies key exports and the public proof, but does not replay contributions or regenerate keys. Existing V1–V3 replay/verification gates are unchanged. +V4 operational verification checks historical payload references through signed +records rather than requiring the large genesis/contribution bytes themselves. +It still checks all required custody, cleanup, enrollment, observer and beacon +evidence. The final-review gate separately requires the coordinator replay claim; +checking an operational bundle alone does not establish that replay. V1–V3 +continue requiring and hashing every historical payload. The review's sorted +dependency list is tested by copying only those files and re-verifying from the +copied definition and signature, without historical contribution binaries. Normal initialization still emits V3. Do not release this slice alone: remaining final-release authoring and the new release/decision verification path are incomplete. These tests are not the normal CLI journey or a full ceremony. diff --git a/internal/mpcceremony/checkpoint_v4_review.go b/internal/mpcceremony/checkpoint_v4_review.go index 2e874171..a4f31de4 100644 --- a/internal/mpcceremony/checkpoint_v4_review.go +++ b/internal/mpcceremony/checkpoint_v4_review.go @@ -19,6 +19,7 @@ type ReleaseReviewV4 struct { 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"` @@ -40,6 +41,12 @@ func (r ReleaseReviewV4) Validate() error { 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") } @@ -164,7 +171,8 @@ func VerifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe if !bytes.Equal(canonical, bb) { return ReleaseReviewV4{}, errors.New("signed bundle does not match the exact review checkpoint") } - if _, err := verifyReleaseOperationalEvidence(d, trusted.CoordinatorPublicKey, candidate, reader.path, filepath.Join(reader.path, bundleRefs.Record.Name), filepath.Join(reader.path, bundleRefs.Signature.Name), releasedAt); err != nil { + 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) @@ -181,6 +189,10 @@ func VerifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe 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 } 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/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index 1f01ba2b..fbf917cc 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -133,7 +133,9 @@ func (p PhaseOperationalEvidence) Validate() error { // 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. +// and resolved below one caller-supplied evidence root. Definition V4 verifies +// historical payload references through signed records, not payload bytes; +// its final review separately requires the coordinator's full-replay claim. type OperationalEvidenceBundle struct { Schema string `json:"schema"` CeremonyID string `json:"ceremony_id"` @@ -282,6 +284,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 @@ -609,15 +613,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 { diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 75552a5c..7c524c3e 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] diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go index ae648a18..457c646f 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -34,6 +34,84 @@ func runCheckpointV4Review(root string, trust m.TrustPaths, d m.CeremonyDefiniti 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 + } + 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].OutboundReceipt.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) + } + } renamedTrust := trust renamedTrust.DefinitionPath = filepath.Join(root, "renamed-trusted-definition.json") definitionBytes, err := os.ReadFile(trust.DefinitionPath) From 4cffdf1a59de731e9a18e2826cf651ee5fa02186 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:31:38 +0900 Subject: [PATCH 17/53] Sign and verify exact V4 release packages with bound coordinator review --- docs/ceremony-schema-compatibility.md | 12 +- internal/mpcceremony/audit.go | 17 +- internal/mpcceremony/chain.go | 17 +- internal/mpcceremony/checkpoint_v4_files.go | 17 +- .../mpcceremony/checkpoint_v4_files_test.go | 3 + internal/mpcceremony/checkpoint_v4_review.go | 21 +- internal/mpcceremony/final_transcript_v3.go | 63 ++++ .../mpcceremony/final_transcript_v3_test.go | 142 +++++++++ internal/mpcceremony/release_layout_v4.go | 121 ++++++++ .../mpcceremony/release_layout_v4_test.go | 90 ++++++ internal/mpcceremony/release_links_other.go | 12 + internal/mpcceremony/release_links_unix.go | 17 + internal/mpcceremony/release_v4.go | 293 ++++++++++++++++++ .../workflowhelper/checkpoint_v4_release.go | 167 ++++++++++ .../workflowhelper/checkpoint_v4_review.go | 3 + 15 files changed, 981 insertions(+), 14 deletions(-) create mode 100644 internal/mpcceremony/final_transcript_v3.go create mode 100644 internal/mpcceremony/final_transcript_v3_test.go create mode 100644 internal/mpcceremony/release_layout_v4.go create mode 100644 internal/mpcceremony/release_layout_v4_test.go create mode 100644 internal/mpcceremony/release_links_other.go create mode 100644 internal/mpcceremony/release_links_unix.go create mode 100644 internal/mpcceremony/release_v4.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 96710714..599d47e4 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -58,9 +58,17 @@ checking an operational bundle alone does not establish that replay. V1–V3 continue requiring and hashing every historical payload. The review's sorted dependency list is tested by copying only those files and re-verifying from the copied definition and signature, without historical contribution binaries. +The V4 library now signs and verifies a local package with an inline exact review +in FinalTranscript V3, while preserving the application manifest V1 and root-level +key files. Only the fixed candidate filename set is relocated; all other logical +names remain unchanged. Independent copies, exact file inventories, copied-byte +review, and destination verification also cover exact retries. The output must +be outside the source tree. Transcript V3 alone has a dedicated 64 MiB bound; +ordinary signed JSON remains limited to 16 MiB. Package time is not proof of +upload, and package signing is not a production GO decision. Normal initialization still emits V3. Do not release this slice alone: remaining -final-release authoring and the new release/decision verification path -are incomplete. These tests are not the normal CLI journey or a full ceremony. +final-release checkpoint authoring, production decision integration and normal +CLI guidance are incomplete. These tests are not a complete user ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed trust and submission rules; do not emit them until the whole verifier path diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 9c2a8f54..38106a99 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -733,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 @@ -1799,12 +1802,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) } @@ -1842,6 +1846,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/chain.go b/internal/mpcceremony/chain.go index da9045d6..5d8a95f9 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -1159,6 +1159,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 +1170,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 = "" @@ -1189,6 +1190,8 @@ func ComputeFinalTranscriptID(record FinalTranscript) (string, error) { domain := "proof-tool/mpc-ceremony/final-transcript/v2" if record.Schema == FinalTranscriptSchemaV1 { domain = "proof-tool/mpc-ceremony/final-transcript/v1" + } else if record.Schema == FinalTranscriptSchemaV3 { + domain = "proof-tool/mpc-ceremony/final-transcript/v3" } return canonicalHash(domain, record) } @@ -1208,8 +1211,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 +1229,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 +1265,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_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 5b5d5e4f..184f5686 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -19,8 +19,9 @@ import ( // 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 + root *os.Root + path string + flatCandidate bool // internal V4 release layout only; never caller-defined aliases } func openCheckpointReaderV4(path string) (*checkpointReaderV4, error) { @@ -58,7 +59,15 @@ func (r *checkpointReaderV4) read(ref ArtifactRef, limit int64, capture bool) ([ if capture && limit > maxSignedRecordBytes { return nil, errors.New("large artifacts must be streamed, not retained in memory") } - parts := strings.Split(ref.Name, "/") + 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]...)) @@ -73,7 +82,7 @@ func (r *checkpointReaderV4) read(ref ArtifactRef, limit int64, capture bool) ([ 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(ref.Name)) + f, err := r.root.Open(filepath.FromSlash(name)) if err != nil { return nil, err } diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 78b8a3be..cc0bbd92 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -78,6 +78,9 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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 scenario.name == "observers-disabled" { testV4CoherentInvalidPublicProof(t, filepath.Join(outputRoot, "ceremony")) } diff --git a/internal/mpcceremony/checkpoint_v4_review.go b/internal/mpcceremony/checkpoint_v4_review.go index a4f31de4..34129674 100644 --- a/internal/mpcceremony/checkpoint_v4_review.go +++ b/internal/mpcceremony/checkpoint_v4_review.go @@ -68,6 +68,10 @@ func (r ReleaseReviewV4) Validate() error { // 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") } @@ -95,6 +99,7 @@ func VerifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe return ReleaseReviewV4{}, err } defer reader.root.Close() + reader.flatCandidate = flatCandidate a, err := loadCheckpointAncestryV4(reader, d, db, ds, head) if err != nil { return ReleaseReviewV4{}, err @@ -129,11 +134,18 @@ func VerifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe // 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 } - candidate, inventory, err := verifyCandidateClosedTree(d, definitionRef, candidateDir, candidate, candidateRef) + verifyTree := verifyCandidateClosedTree + if flatCandidate { + verifyTree = verifyCandidateSubsetV4 + } + candidate, inventory, err := verifyTree(d, definitionRef, candidateDir, candidate, candidateRef) if err != nil { return ReleaseReviewV4{}, err } @@ -196,6 +208,13 @@ func VerifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe 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 } 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/release_layout_v4.go b/internal/mpcceremony/release_layout_v4.go new file mode 100644 index 00000000..1a2fec6c --- /dev/null +++ b/internal/mpcceremony/release_layout_v4.go @@ -0,0 +1,121 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path/filepath" + "reflect" + "slices" + "strings" + + "proof-tool/internal/keybundle" +) + +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..b19debdb --- /dev/null +++ b/internal/mpcceremony/release_v4.go @@ -0,0 +1,293 @@ +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 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 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 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 := verifyChecksumsExact(o.KeysDir, filepath.Join(o.KeysDir, ReleaseChecksumsFile), names); 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/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_review.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go index 457c646f..0448fda3 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -112,6 +112,9 @@ func runCheckpointV4Review(root string, trust m.TrustPaths, d m.CeremonyDefiniti 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 + } renamedTrust := trust renamedTrust.DefinitionPath = filepath.Join(root, "renamed-trusted-definition.json") definitionBytes, err := os.ReadFile(trust.DefinitionPath) From e8a1c95bf70097874c5fece8a6f7f22f1ee55d0a Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:40:11 +0900 Subject: [PATCH 18/53] Bound V4 release checksums for maximum review inventories --- internal/mpcceremony/audit.go | 58 +++++++++++------- .../mpcceremony/release_checksums_v4_test.go | 60 +++++++++++++++++++ internal/mpcceremony/release_layout_v4.go | 4 ++ internal/mpcceremony/release_v4.go | 2 +- 4 files changed, 103 insertions(+), 21 deletions(-) create mode 100644 internal/mpcceremony/release_checksums_v4_test.go diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 38106a99..224c6667 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -1294,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 } @@ -1302,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 { 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_layout_v4.go b/internal/mpcceremony/release_layout_v4.go index 1a2fec6c..f9876ae2 100644 --- a/internal/mpcceremony/release_layout_v4.go +++ b/internal/mpcceremony/release_layout_v4.go @@ -11,6 +11,10 @@ import ( "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 diff --git a/internal/mpcceremony/release_v4.go b/internal/mpcceremony/release_v4.go index b19debdb..16fe8639 100644 --- a/internal/mpcceremony/release_v4.go +++ b/internal/mpcceremony/release_v4.go @@ -279,7 +279,7 @@ func VerifyReleaseV4(o VerifyReleaseV4Options) (*VerifyReleaseResult, error) { return nil, err } names = append(names, FinalTranscriptFile, keybundle.ManifestFile, keybundle.ManifestSignatureFile, keybundle.ManifestPublicKeyFile) - if err := verifyChecksumsExact(o.KeysDir, filepath.Join(o.KeysDir, ReleaseChecksumsFile), names); err != nil { + 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 { From 2a2a42d3f5e9ce92a190d5c9c9556cc988a7e373 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 05:52:44 +0900 Subject: [PATCH 19/53] Record exact private V4 release packages in ceremony checkpoints --- docs/ceremony-schema-compatibility.md | 12 +- internal/mpcceremony/checkpoint_v4.go | 12 +- internal/mpcceremony/checkpoint_v4_files.go | 6 + .../mpcceremony/checkpoint_v4_files_test.go | 3 + internal/mpcceremony/checkpoint_v4_release.go | 168 ++++++++++++++++++ .../mpcceremony/checkpoint_v4_release_test.go | 162 +++++++++++++++++ internal/mpcceremony/checkpoint_v4_test.go | 6 + .../checkpoint_v4_release_checkpoint.go | 124 +++++++++++++ .../workflowhelper/checkpoint_v4_review.go | 3 + 9 files changed, 492 insertions(+), 4 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_release.go create mode 100644 internal/mpcceremony/checkpoint_v4_release_test.go create mode 100644 internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_release_checkpoint.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 599d47e4..bb724818 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -66,9 +66,17 @@ review, and destination verification also cover exact retries. The output must be outside the source tree. Transcript V3 alone has a dedicated 64 MiB bound; ordinary signed JSON remains limited to 16 MiB. Package time is not proof of upload, and package signing is not a production GO decision. +Final-release checkpoint authoring verifies that complete package and binds its +exact review predecessor. The checkpoint adds only five canonical bootstrap +references under `final/release/`; a typed inventory returns all package-relative +files separately from that prefix. `VerifyStoredCheckpointV4` remains structural +inspection; `VerifyFinalReleaseCheckpointV4` additionally verifies package bytes. +Recording this edge means a signed private package, not public publication or GO. +Only this edge has five reserved artifact slots and the exact transcript and +checksum size exceptions. Released checkpoint formats keep their limits. Normal initialization still emits V3. Do not release this slice alone: remaining -final-release checkpoint authoring, production decision integration and normal -CLI guidance are incomplete. These tests are not a complete user ceremony. +production decision integration and normal CLI guidance are incomplete. These +tests are not a complete user ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed trust and submission rules; do not emit them until the whole verifier path diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index c0181fec..d66054cd 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -139,7 +139,11 @@ func (c CheckpointV4) Validate() error { } } } - if err := validateV4ArtifactSet(c.AcceptedArtifacts, MaxCheckpointArtifacts); err != nil { + 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 { @@ -325,7 +329,11 @@ func (t CheckpointTransitionV4) Validate() error { if len(t.Evidence) != 1 { return errors.New("lifecycle transition requires exactly one payload artifact") } - case CheckpointFinalCandidateRecorded, CheckpointFinalReleaseRecorded: + 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") } diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 184f5686..c7051522 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -338,6 +338,9 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { } 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 } @@ -453,6 +456,9 @@ func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *Truste 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") } diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index cc0bbd92..97ea2bc7 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -81,6 +81,9 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { 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")) } diff --git a/internal/mpcceremony/checkpoint_v4_release.go b/internal/mpcceremony/checkpoint_v4_release.go new file mode 100644 index 00000000..5b2f6bd7 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_release.go @@ -0,0 +1,168 @@ +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) } + +// 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 +} + +// 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 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 + } + 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..749eda4d --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_release_test.go @@ -0,0 +1,162 @@ +package mpcceremony + +import ( + "fmt" + "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 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) { + d, 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} { + 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: CheckpointFinalCandidateRecorded, Record: c.Progress.FinalCandidate, Evidence: []ArtifactRef{payload}, ReplayVerification: &CheckpointReplayVerificationV4{Method: CoordinatorReplayReleaseV1, ToolBinary: d.Software.ToolBinary}} + 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_test.go b/internal/mpcceremony/checkpoint_v4_test.go index 73374193..6015c6f4 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -7,6 +7,8 @@ import ( "fmt" "strings" "testing" + + "proof-tool/internal/keybundle" ) // These fixtures test structural guidance transitions, not contribution math. @@ -143,6 +145,10 @@ func TestCheckpointV4FullStructuralLifecycle(t *testing.T) { if kind != CheckpointPhase1Closed && kind != CheckpointPhase2Closed { 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: 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 index 0448fda3..d3b9b064 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -115,6 +115,9 @@ func runCheckpointV4Review(root string, trust m.TrustPaths, d m.CeremonyDefiniti 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) From 4e6d3d32a43f4995931a0311a9a947be54f9fe6c Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:13:56 +0900 Subject: [PATCH 20/53] Bind V4 production decisions to exact verified release packages --- docs/ceremony-schema-compatibility.md | 23 +- internal/mpcceremony/decision_v3.go | 357 ++++++++++++++++++ internal/mpcceremony/decision_v3_draft.go | 69 ++++ internal/mpcceremony/decision_v3_test.go | 242 ++++++++++++ internal/mpcceremony/decision_v3_verify.go | 313 +++++++++++++++ .../mpcceremony/decision_v3_verify_test.go | 284 ++++++++++++++ 6 files changed, 1283 insertions(+), 5 deletions(-) create mode 100644 internal/mpcceremony/decision_v3.go create mode 100644 internal/mpcceremony/decision_v3_draft.go create mode 100644 internal/mpcceremony/decision_v3_test.go create mode 100644 internal/mpcceremony/decision_v3_verify.go create mode 100644 internal/mpcceremony/decision_v3_verify_test.go diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index bb724818..8912221b 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -74,9 +74,22 @@ inspection; `VerifyFinalReleaseCheckpointV4` additionally verifies package bytes Recording this edge means a signed private package, not public publication or GO. Only this edge has five reserved artifact slots and the exact transcript and checksum size exceptions. Released checkpoint formats keep their limits. +The Decision V3 library now binds that compact release checkpoint, derives the +exact auditor signer set from its package, and preserves all production gates. +It requires a production-mode Definition V4 and the exact K21 circuit; tiny or +rehearsal-mode definitions cannot receive production approval. Source evidence +is a bounded report under `decision/evidence/`, not an obsolete GPG tag or a +private URL. Proof-tool binds the source commit/report and decision signatures; +the delivery tool must verify and display CI provenance before approval. +Package-derived gates are checked facts; external gates remain reviewed claims. +An independently reloaded package must match the initially authenticated +definition exactly. Legacy decision structs, gates and hash domains are unchanged. +Current tests cover the new record/binding/signature/evidence rules and reject a +missing package. A full public-API positive with a real K21 package is pending; +these tests do not establish an actual production GO or operational assurances. Normal initialization still emits V3. Do not release this slice alone: remaining -production decision integration and normal CLI guidance are incomplete. These -tests are not a complete user ceremony. +production decision CLI/integration and normal CLI guidance are incomplete. +These tests are not a complete user ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed trust and submission rules; do not emit them until the whole verifier path @@ -87,9 +100,9 @@ tests are not a complete user ceremony. without changing ordinary application key-bundle verification or adding a second authorization signature. Include the checkpoint pair in the closed ceremony release inventory. -- Explicitly dispatch Definition V4 to final transcript V3. Decision V2 may - retain its structure if exact definition/release binding and V4 verification - are enforced; it must not fall through to legacy policy. Reuse component +- Explicitly dispatch Definition V4 to final transcript V3 and Decision V3. + Decision V2's enumerated tree and bounds cannot represent every V4 package; + do not widen its released limits or fall through to legacy policy. Reuse component evidence/candidate formats only where their exact signed meaning stays unchanged. - Keep old signing and verification dispatch intact. Unknown versions fail 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..c9c280ac --- /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 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 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..8cccd463 --- /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 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") + } +} From cb9fa69dc9506c2e7df9a8ff78c1d29f9b2cf167 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:25:36 +0900 Subject: [PATCH 21/53] Route production decision CLI through authenticated V4 verification --- cmd/mpc-ceremony/decision.go | 18 +++- cmd/mpc-ceremony/decision_v4.go | 131 ++++++++++++++++++++++++ cmd/mpc-ceremony/decision_v4_test.go | 140 ++++++++++++++++++++++++++ cmd/mpc-ceremony/parse.go | 6 ++ cmd/mpc-ceremony/types.go | 1 + cmd/mpc-ceremony/usage.go | 21 ++-- docs/ceremony-schema-compatibility.md | 9 +- 7 files changed, 318 insertions(+), 8 deletions(-) create mode 100644 cmd/mpc-ceremony/decision_v4.go create mode 100644 cmd/mpc-ceremony/decision_v4_test.go 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..97f908df --- /dev/null +++ b/cmd/mpc-ceremony/decision_v4.go @@ -0,0 +1,131 @@ +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") + } + packagePath, err := filepath.Abs(filepath.Join(root, "final", "release")) + 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("decision output must be outside the immutable final/release package") + } + 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, "final", "release"), filepath.Join(parent, filepath.Base(outputPath))) { + return errors.New("decision output resolves inside the immutable final/release package") + } + 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/parse.go b/cmd/mpc-ceremony/parse.go index a9b1adf8..22b7bcec 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -472,10 +472,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), diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 3f51117d..0f1ad5a0 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -586,6 +586,7 @@ type DecisionPrepareOptions struct { CeremonySignaturePath string CoordinatorPublicKeyFile string DraftPath string + EvidenceRoot string OutPath string } diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index cdcd888c..6474384c 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -603,13 +603,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 \ @@ -623,9 +627,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 \ @@ -635,8 +640,12 @@ 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] diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 8912221b..ddad6ffe 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -87,8 +87,15 @@ definition exactly. Legacy decision structs, gates and hash domains are unchange Current tests cover the new record/binding/signature/evidence rules and reject a missing package. A full public-API positive with a real K21 package is pending; these tests do not establish an actual production GO or operational assurances. +Decision prepare/sign/verify now dispatch from the authenticated definition. +V4 requires a local evidence root even for post-package NO-GO, checks evidence +before loading the signing key, and keeps decision outputs outside the immutable +release package. Early stops use the existing authenticated abort procedure. +Old decision behavior remains unchanged; only V4 accepts evidence-root during +preparation. CLI tests cover signed format dispatch, missing roots, evidence +failure before key loading, and output containment including symlink aliases. Normal initialization still emits V3. Do not release this slice alone: remaining -production decision CLI/integration and normal CLI guidance are incomplete. +production integration and normal storage-first CLI guidance are incomplete. These tests are not a complete user ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed From bf9029aebc5d422fb4deb0d40695fefb0a8d54b6 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:35:41 +0900 Subject: [PATCH 22/53] Wire V4 release package signing and verification without duplicate replay --- cmd/mpc-ceremony/executor.go | 30 +++- cmd/mpc-ceremony/integration_test.go | 2 + cmd/mpc-ceremony/parse.go | 12 +- cmd/mpc-ceremony/release_v4.go | 56 +++++++ cmd/mpc-ceremony/release_v4_test.go | 140 ++++++++++++++++++ cmd/mpc-ceremony/types.go | 2 + cmd/mpc-ceremony/usage.go | 17 ++- docs/ceremony-schema-compatibility.md | 7 + internal/mpcceremony/release_v4.go | 13 ++ .../mpcceremony/release_v4_software_test.go | 17 +++ 10 files changed, 290 insertions(+), 6 deletions(-) create mode 100644 cmd/mpc-ceremony/release_v4.go create mode 100644 cmd/mpc-ceremony/release_v4_test.go create mode 100644 internal/mpcceremony/release_v4_software_test.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index efcaf97f..abed0cbe 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -666,9 +666,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 @@ -724,7 +734,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) @@ -735,6 +749,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..52c15397 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -175,6 +175,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--release-dir", "--release-signing-key", "--released-at", + "--review-checkpoint", + "--review-checkpoint-signature", "--record", "--record-type", "--canonical", diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 22b7bcec..a1fc905f 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -1246,6 +1246,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") @@ -1265,7 +1267,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), @@ -1276,6 +1277,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..dea4fff9 --- /dev/null +++ b/cmd/mpc-ceremony/release_v4_test.go @@ -0,0 +1,140 @@ +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") + 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/types.go b/cmd/mpc-ceremony/types.go index 0f1ad5a0..6c1a1dbc 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -255,6 +255,8 @@ type ReleaseSignOptions struct { CeremonySignaturePath string CoordinatorPublicKeyFile string CandidateBundleDir string + ReviewCheckpointPath string + ReviewSignaturePath string AuditReportPaths []string AuditSignaturePaths []string OperationalEvidenceRoot string diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 6474384c..e109a752 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -581,9 +581,18 @@ Release authenticity is separate from MPC contribution identity. 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. + 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 without replaying contributions. 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 \ @@ -593,6 +602,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] diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index ddad6ffe..da80904f 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -94,6 +94,13 @@ release package. Early stops use the existing authenticated abort procedure. Old decision behavior remains unchanged; only V4 accepts evidence-root during preparation. CLI tests cover signed format dispatch, missing roots, evidence failure before key loading, and output containment including symlink aliases. +Release sign now accepts an exact review checkpoint pair for V4 instead of +legacy candidate/audit/replay flags; mixing the two input forms is rejected. +Metadata references are root-confined and size-bounded. The signing library +rechecks the running executable against its authenticated definition before +loading the release key. Release verify selects the V4 package verifier from +the signed definition. These commands create/check local packages, not public +publication or production authorization; legacy V3 signer replay is unchanged. Normal initialization still emits V3. Do not release this slice alone: remaining production integration and normal storage-first CLI guidance are incomplete. These tests are not a complete user ceremony. diff --git a/internal/mpcceremony/release_v4.go b/internal/mpcceremony/release_v4.go index 16fe8639..53185a63 100644 --- a/internal/mpcceremony/release_v4.go +++ b/internal/mpcceremony/release_v4.go @@ -51,6 +51,9 @@ func SignReleaseV4(o SignReleaseV4Options) (*SignReleaseResult, error) { 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") } @@ -168,6 +171,16 @@ func SignReleaseV4(o SignReleaseV4Options) (*SignReleaseResult, error) { 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 { 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) + } +} From cf8ef121a43a03fec193d72bb444f246991aef8e Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 06:50:25 +0900 Subject: [PATCH 23/53] Expose opt-in V4 checkpoint preparation signing and structural inspection --- cmd/mpc-ceremony/checkpoint_command.go | 7 + cmd/mpc-ceremony/checkpoint_v4.go | 228 +++++++++++++++++++++++++ cmd/mpc-ceremony/checkpoint_v4_test.go | 215 +++++++++++++++++++++++ cmd/mpc-ceremony/decision_v4.go | 14 +- cmd/mpc-ceremony/executor.go | 27 +-- cmd/mpc-ceremony/integration_test.go | 12 ++ cmd/mpc-ceremony/main.go | 6 +- cmd/mpc-ceremony/parse.go | 4 + cmd/mpc-ceremony/types.go | 5 + cmd/mpc-ceremony/usage.go | 45 ++++- docs/ceremony-schema-compatibility.md | 13 ++ 11 files changed, 556 insertions(+), 20 deletions(-) create mode 100644 cmd/mpc-ceremony/checkpoint_v4.go create mode 100644 cmd/mpc-ceremony/checkpoint_v4_test.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 83e5e20d..a55e0fc3 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -45,6 +45,10 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"checkpoint"}, args[1:]...)} } switch args[0] { + case "prepare-v4", "sign-v4", "verify-stored-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 @@ -589,6 +593,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) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go new file mode 100644 index 00000000..cee9fb52 --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -0,0 +1,228 @@ +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 string +} + +type CheckpointInspectionV4 struct { + Schema string `json:"schema"` + Depth string `json:"depth"` + Checkpoint m.CheckpointV4 `json:"checkpoint"` + CheckpointRefs m.SignedArtifactRefs `json:"checkpoint_refs"` + ArtifactsVerified bool `json:"artifacts_verified"` + MathematicsReplayed bool `json:"mathematics_replayed"` + GlobalFreshnessVerified bool `json:"global_freshness_verified"` +} + +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 action == "verify-stored-v4" { + fs.StringVar(&o.CheckpointPath, "checkpoint", "", "exact checkpoint under artifact-root") + fs.StringVar(&o.CheckpointSignaturePath, "checkpoint-signature", "", "exact detached checkpoint signature under artifact-root") + } 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 action == "verify-stored-v4" { + return o, requireValues(pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath)) + } + 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.CheckpointPhase1OutboundPublished, m.CheckpointPhase2OutboundPublished, + m.CheckpointPhase1ReceiptAccepted, m.CheckpointPhase2ReceiptAccepted, + m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, + m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, + m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, + m.CheckpointWitnessRecorded, m.CheckpointBeaconEvidenceRecorded, 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 command == CommandCheckpointVerifyStoredV4 { + _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) + if err != nil { + return CommandResult{}, err + } + c, err := m.VerifyStoredCheckpointV4(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}}, 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}) + 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 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 +} diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go new file mode 100644 index 00000000..fa3baa9d --- /dev/null +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -0,0 +1,215 @@ +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.CheckpointPhase1OutboundPublished, m.CheckpointPhase2OutboundPublished, m.CheckpointPhase1ReceiptAccepted, m.CheckpointPhase2ReceiptAccepted, m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, m.CheckpointWitnessRecorded, m.CheckpointBeaconEvidenceRecorded, 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 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) + } + 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, 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} + 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") + // 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/decision_v4.go b/cmd/mpc-ceremony/decision_v4.go index 97f908df..92938b12 100644 --- a/cmd/mpc-ceremony/decision_v4.go +++ b/cmd/mpc-ceremony/decision_v4.go @@ -97,7 +97,13 @@ func validateDecisionOutputV4(root, out string) error { if root == "" { return errors.New("--evidence-root is required for definition v4 decisions") } - packagePath, err := filepath.Abs(filepath.Join(root, "final", "release")) + 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 } @@ -110,7 +116,7 @@ func validateDecisionOutputV4(root, out string) error { return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) } if inside(packagePath, outputPath) { - return errors.New("decision output must be outside the immutable final/release package") + return errors.New("path must be outside the closed artifact tree") } resolvedRoot, err := filepath.EvalSymlinks(root) if err != nil { @@ -124,8 +130,8 @@ func validateDecisionOutputV4(root, out string) error { if err != nil { return err } - if inside(filepath.Join(resolvedRoot, "final", "release"), filepath.Join(parent, filepath.Base(outputPath))) { - return errors.New("decision output resolves inside the immutable final/release package") + 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/executor.go b/cmd/mpc-ceremony/executor.go index abed0cbe..6a9c7d5a 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -121,6 +121,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeSubmissionAccept(invocation.Options.(SubmissionAcceptOptions)) case CommandCheckpointPrepare: return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) + case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointVerifyStoredV4: + return executeCheckpointV4(invocation.Command, invocation.Options.(CheckpointOptionsV4)) case CommandCheckpointSign: return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) case CommandCheckpointVerify: @@ -188,18 +190,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, }) diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 52c15397..e261988f 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -47,6 +47,9 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"decision", "prepare"}, {"decision", "sign"}, {"decision", "verify"}, + {"checkpoint", "prepare-v4"}, + {"checkpoint", "sign-v4"}, + {"checkpoint", "verify-stored-v4"}, {"inspect"}, {"inspect", "definition"}, {"inspect", "chain"}, @@ -177,6 +180,12 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--released-at", "--review-checkpoint", "--review-checkpoint-signature", + "--release-verification", + "--proposal", + "--rejected-candidate-dir", + "--artifact-root", + "--checkpoint", + "--checkpoint-signature", "--record", "--record-type", "--canonical", @@ -240,6 +249,9 @@ 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: CommandCheckpointVerifyStoredV4, Options: CheckpointOptionsV4{}}, } for _, invocation := range tests { t.Run(string(invocation.Command), func(t *testing.T) { diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index e7d10be4..d9e9cb4a 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -271,7 +271,7 @@ 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": {}, "verify-stored-v4": {}}, "inspect": { "chain": {}, "checkpoint": {}, "checkpoint-transition": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, }, @@ -346,7 +346,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": safe[index] = struct{}{} } if index > 0 && args[index-1] == "--record-type" { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index a1fc905f..fc7c09d4 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -849,6 +849,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)") @@ -863,6 +864,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 diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 6c1a1dbc..cc479985 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -64,6 +64,9 @@ const ( CommandCheckpointSign Command = "checkpoint sign" CommandCheckpointVerify Command = "checkpoint verify" CommandCheckpointVerifyStored Command = "checkpoint verify-stored" + CommandCheckpointPrepareV4 Command = "checkpoint prepare-v4" + CommandCheckpointSignV4 Command = "checkpoint sign-v4" + CommandCheckpointVerifyStoredV4 Command = "checkpoint verify-stored-v4" ) type SubmissionSignOptions struct { @@ -106,6 +109,7 @@ type IdentityGenerateOptions struct { } type InitOptions struct { + ReleaseVerification string SessionNonceHex string CreatedAt string KeyVersion string @@ -646,6 +650,7 @@ type CommandResult struct { 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"` 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 e109a752..2bc91d4d 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -237,16 +237,52 @@ 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 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": `Usage: mpc-ceremony checkpoint [flags] + mpc-ceremony checkpoint [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 canonical protocol proposals rather than legacy +submission 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. `, "submission": `Usage: mpc-ceremony submission [flags] @@ -351,7 +387,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, @@ -359,6 +396,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] diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index da80904f..5f878d0a 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -103,6 +103,19 @@ the signed definition. These commands create/check local packages, not public publication or production authorization; legacy V3 signer replay is unchanged. Normal initialization still emits V3. Do not release this slice alone: remaining production integration and normal storage-first CLI guidance are incomplete. +An explicit `init --release-verification coordinator-full-replay-v1` now opts a +fresh ceremony into V4; it never upgrades an existing definition. The separate +`checkpoint prepare-v4`, `sign-v4`, and `verify-stored-v4` commands leave legacy +parsers unchanged. Signing repeats preparation and preserves exact canonical +bytes before key loading. Only the six mathematical transition types load the +authenticated stored R1CS. Structural inspection emits a versioned projection +with explicit false artifact/replay/freshness claims. Prepared and signed local +proposals are not published current heads. The tiny executable regression creates +V3 and V4 ceremonies, then prepares/signs/inspects initial V4 state and rejects +changed genesis before signing; this is not yet a whole storage-backed role journey. +Proposal/output paths cannot enter closed candidate/release/rejection trees, +including symlink aliases. Private rejected candidates must be disjoint from +the public artifact root. Existing output files are retained for inspection. These tests are not a complete user ceremony. - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed From 5c143c571c14738fdfd3c0d2cbeedf43a151a41c Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:21:00 +0900 Subject: [PATCH 24/53] Add checkpoint-bound V4 bundle and release evidence commands --- cmd/mpc-ceremony/checkpoint_command.go | 4 + cmd/mpc-ceremony/evidence_v4.go | 334 +++++++++++++ cmd/mpc-ceremony/evidence_v4_test.go | 473 ++++++++++++++++++ cmd/mpc-ceremony/executor.go | 2 + cmd/mpc-ceremony/integration_test.go | 12 + cmd/mpc-ceremony/main.go | 8 +- cmd/mpc-ceremony/ops.go | 12 +- cmd/mpc-ceremony/ops_bundle.go | 3 + cmd/mpc-ceremony/ops_guided.go | 3 + cmd/mpc-ceremony/parse.go | 9 + cmd/mpc-ceremony/release_v4_test.go | 5 + cmd/mpc-ceremony/types.go | 5 + cmd/mpc-ceremony/usage.go | 65 ++- docs/ceremony-schema-compatibility.md | 20 + internal/mpcceremony/checkpoint_v4_release.go | 23 + .../workflowhelper/checkpoint_v4_review.go | 6 +- .../testdata/workflowhelper/main.go | 10 + 17 files changed, 984 insertions(+), 10 deletions(-) create mode 100644 cmd/mpc-ceremony/evidence_v4.go create mode 100644 cmd/mpc-ceremony/evidence_v4_test.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index a55e0fc3..82343f24 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -45,6 +45,10 @@ 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", "verify-stored-v4": options, err := parseCheckpointV4(args[0], args[1:]) invocation.Command, invocation.Options = Command("checkpoint "+args[0]), options diff --git a/cmd/mpc-ceremony/evidence_v4.go b/cmd/mpc-ceremony/evidence_v4.go new file mode 100644 index 00000000..a44be1a2 --- /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..4351b40b --- /dev/null +++ b/cmd/mpc-ceremony/evidence_v4_test.go @@ -0,0 +1,473 @@ +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 + } + 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].ReturnReceipt.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 6a9c7d5a..71e87198 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: diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index e261988f..e8941039 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -50,6 +50,10 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"checkpoint", "prepare-v4"}, {"checkpoint", "sign-v4"}, {"checkpoint", "verify-stored-v4"}, + {"checkpoint", "verify-release-v4"}, + {"release", "review-v4"}, + {"ops", "prepare-bundle-v4"}, + {"ops", "sign-bundle-v4"}, {"inspect"}, {"inspect", "definition"}, {"inspect", "chain"}, @@ -180,9 +184,13 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--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", @@ -252,6 +260,10 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandCheckpointPrepareV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointSignV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointVerifyStoredV4, Options: CheckpointOptionsV4{}}, + {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) { diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index d9e9cb4a..2c416f07 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -271,16 +271,16 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "verify-stored-v4": {}}, + "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}}, "inspect": { "chain": {}, "checkpoint": {}, "checkpoint-transition": {}, "definition": {}, "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]] @@ -348,7 +348,7 @@ func markOperationalGrammar(args []string, safe map[int]struct{}) { for index, arg := range args { switch arg { case "--related-record", "--record-type", "--reviewed-sha256", "--evidence-root", - "--release-verification", "--review-checkpoint", "--review-checkpoint-signature", "--proposal", "--rejected-candidate-dir": + "--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 fc7c09d4..41a5131d 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -576,6 +576,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 @@ -829,6 +834,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 diff --git a/cmd/mpc-ceremony/release_v4_test.go b/cmd/mpc-ceremony/release_v4_test.go index dea4fff9..b1a0ecd0 100644 --- a/cmd/mpc-ceremony/release_v4_test.go +++ b/cmd/mpc-ceremony/release_v4_test.go @@ -54,6 +54,11 @@ func TestReleaseV4ExecutableAuthenticatesBeforeDispatch(t *testing.T) { 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) diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index cc479985..77aa7ee9 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" @@ -67,6 +70,7 @@ const ( CommandCheckpointPrepareV4 Command = "checkpoint prepare-v4" CommandCheckpointSignV4 Command = "checkpoint sign-v4" CommandCheckpointVerifyStoredV4 Command = "checkpoint verify-stored-v4" + CommandCheckpointVerifyReleaseV4 Command = "checkpoint verify-release-v4" ) type SubmissionSignOptions struct { @@ -651,6 +655,7 @@ type CommandResult struct { 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 2bc91d4d..325c945f 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -268,10 +268,21 @@ successfully updates the head. Existing outputs require inspection, not overwrit 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] Legacy storage-first checkpoint operations re-authenticate the exact signed definition, predecessor, both phase chains and all records @@ -603,9 +614,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 \ @@ -701,6 +726,7 @@ 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 @@ -786,6 +812,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 \ @@ -804,6 +855,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 \ @@ -812,6 +864,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 \ @@ -822,6 +876,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 \ @@ -832,7 +887,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-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 5f878d0a..3e5a12a6 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -118,6 +118,26 @@ including symlink aliases. Private rejected candidates must be disjoint from the public artifact root. Existing output files are retained for inspection. These tests are not a complete user ceremony. +The explicit V4 evidence commands bind bundle preparation and signing to an +exact checkpoint pair; signing rederives the reviewed canonical bytes before +loading the coordinator key. Outputs use the canonical operational bundle paths, +and legacy bundle preparation/signing rejects V4. `release review-v4` writes a +bounded unsigned local report; signing recomputes the review and does not accept +that report as authority. `checkpoint verify-release-v4` verifies the complete +private package and exports a bounded, package-relative inventory report. That +report is not a trusted download list or a production authorization. The delivery +tool must use authenticated bootstrap files and rerun verification after download. +Both reports stay outside the closed candidate and release trees. Ordinary record +and signature bounds remain 16 MiB and 4 KiB; only these large local reports use +64 MiB. The Linux ARM64 integration invokes the actual approved CLI against real +tiny artifacts, using a helper and command binary with distinct approved ARM64 +feature variants. A modified command executable fails before output or key access. +The test uses known local keys and historical beacon responses, not independent +operators or a live storage journey. Legacy bundle export/import also rejects V4; +generic bundle verification explicitly does not establish exact-head equivalence. +Inventory reports have strict canonical shape/claim validation, which does not +make them trusted authority. A full released CLI journey is still required. + - Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed trust and submission rules; do not emit them until the whole verifier path exists and has negative tests. diff --git a/internal/mpcceremony/checkpoint_v4_release.go b/internal/mpcceremony/checkpoint_v4_release.go index 5b2f6bd7..69a6244b 100644 --- a/internal/mpcceremony/checkpoint_v4_release.go +++ b/internal/mpcceremony/checkpoint_v4_release.go @@ -24,6 +24,26 @@ 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) { @@ -164,5 +184,8 @@ func verifyFinalReleasePackageV4(trust TrustPaths, root string, c CheckpointV4) 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/testdata/workflowhelper/checkpoint_v4_review.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go index d3b9b064..11634913 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -40,7 +40,11 @@ func runCheckpointV4Review(root string, trust m.TrustPaths, d m.CeremonyDefiniti if err != nil { return err } - defer os.RemoveAll(snapshot) + // 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 { diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 98110add..8153e971 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -104,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 } From 9841387c3bc2e659ffa36eeb15cf5db29d3fe3d9 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:44:25 +0900 Subject: [PATCH 25/53] Add authenticated protocol and bounded checkpoint discovery --- cmd/mpc-ceremony/checkpoint_command.go | 2 +- cmd/mpc-ceremony/checkpoint_v4.go | 36 ++- cmd/mpc-ceremony/checkpoint_v4_test.go | 6 + cmd/mpc-ceremony/definition_protocol.go | 32 +++ cmd/mpc-ceremony/definition_protocol_test.go | 68 ++++++ cmd/mpc-ceremony/executor.go | 4 +- cmd/mpc-ceremony/integration_test.go | 2 + cmd/mpc-ceremony/main.go | 4 +- cmd/mpc-ceremony/parse.go | 4 + cmd/mpc-ceremony/types.go | 4 + cmd/mpc-ceremony/usage.go | 19 ++ docs/ceremony-schema-compatibility.md | 11 + .../mpcceremony/checkpoint_v4_discovery.go | 55 +++++ .../checkpoint_v4_discovery_test.go | 215 ++++++++++++++++++ 14 files changed, 456 insertions(+), 6 deletions(-) create mode 100644 cmd/mpc-ceremony/definition_protocol.go create mode 100644 cmd/mpc-ceremony/definition_protocol_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_discovery.go create mode 100644 internal/mpcceremony/checkpoint_v4_discovery_test.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 82343f24..90cbcc3c 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -49,7 +49,7 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { options, err := parseEvidenceV4(CommandCheckpointVerifyReleaseV4, args[1:]) invocation.Command, invocation.Options = CommandCheckpointVerifyReleaseV4, options return invocation, wrapCommandError(err, "checkpoint", args[0]) - case "prepare-v4", "sign-v4", "verify-stored-v4": + case "prepare-v4", "sign-v4", "verify-stored-v4", "inspect-signed-v4": options, err := parseCheckpointV4(args[0], args[1:]) invocation.Command, invocation.Options = Command("checkpoint "+args[0]), options return invocation, wrapCommandError(err, "checkpoint", args[0]) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index cee9fb52..c9ddc733 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -27,12 +27,23 @@ type CheckpointInspectionV4 struct { 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"` +} + 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 action == "verify-stored-v4" { + if action == "verify-stored-v4" || action == "inspect-signed-v4" { fs.StringVar(&o.CheckpointPath, "checkpoint", "", "exact checkpoint under artifact-root") fs.StringVar(&o.CheckpointSignaturePath, "checkpoint-signature", "", "exact detached checkpoint signature under artifact-root") } else { @@ -49,7 +60,7 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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 action == "verify-stored-v4" { + if action == "verify-stored-v4" || action == "inspect-signed-v4" { return o, requireValues(pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath)) } if action != "prepare-v4" && action != "sign-v4" { @@ -100,6 +111,27 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, if err := m.VerifyRunningSoftwareForMode(d.Software, d.Mode); err != nil { return CommandResult{}, err } + 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 { diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index fa3baa9d..82203f3a 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -170,6 +170,12 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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) + } // Sign again only after rereading every required byte, not a saved success marker. genesis := filepath.Join(artifactRoot, payload.Name) original := mustReadTestFile(t, genesis) diff --git a/cmd/mpc-ceremony/definition_protocol.go b/cmd/mpc-ceremony/definition_protocol.go new file mode 100644 index 00000000..8461415f --- /dev/null +++ b/cmd/mpc-ceremony/definition_protocol.go @@ -0,0 +1,32 @@ +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"` +} + +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), + } + 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..1bced5e0 --- /dev/null +++ b/cmd/mpc-ceremony/definition_protocol_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "bytes" + "context" + "encoding/json" + "testing" + + 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) + } + 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/executor.go b/cmd/mpc-ceremony/executor.go index 71e87198..7d0c29af 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -103,6 +103,8 @@ 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 CommandInspectParticipant: @@ -123,7 +125,7 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeSubmissionAccept(invocation.Options.(SubmissionAcceptOptions)) case CommandCheckpointPrepare: return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) - case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointVerifyStoredV4: + case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4: return executeCheckpointV4(invocation.Command, invocation.Options.(CheckpointOptionsV4)) case CommandCheckpointSign: return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index e8941039..f9221ae1 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -50,12 +50,14 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"checkpoint", "prepare-v4"}, {"checkpoint", "sign-v4"}, {"checkpoint", "verify-stored-v4"}, + {"checkpoint", "inspect-signed-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"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 2c416f07..ae2bdad0 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -271,9 +271,9 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}}, + "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}, "inspect-signed-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": {}, diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 41a5131d..7a72a025 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -214,6 +214,10 @@ 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 "definition": options, err := parseInspectDefinition(args[1:]) invocation.Command, invocation.Options = CommandInspectDefinition, options diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 77aa7ee9..4412e764 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -54,6 +54,7 @@ const ( CommandDecisionSign Command = "decision sign" CommandDecisionVerify Command = "decision verify" CommandInspectDefinition Command = "inspect definition" + CommandInspectDefinitionProtocol Command = "inspect definition-protocol" CommandInspectChain Command = "inspect chain" CommandInspectParticipant Command = "inspect participant" CommandInspectEnrollment Command = "inspect enrollment" @@ -70,6 +71,7 @@ const ( CommandCheckpointPrepareV4 Command = "checkpoint prepare-v4" CommandCheckpointSignV4 Command = "checkpoint sign-v4" CommandCheckpointVerifyStoredV4 Command = "checkpoint verify-stored-v4" + CommandCheckpointInspectSignedV4 Command = "checkpoint inspect-signed-v4" CommandCheckpointVerifyReleaseV4 Command = "checkpoint verify-release-v4" ) @@ -648,6 +650,8 @@ 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"` + CheckpointDiscoveryV4 *CheckpointDiscoveryInspectionV4 `json:"checkpoint_discovery_v4,omitempty"` ChainInspection *ChainInspection `json:"chain_inspection,omitempty"` ParticipantInspection *ParticipantInspection `json:"participant_inspection,omitempty"` EnrollmentInspection *EnrollmentInspection `json:"enrollment_inspection,omitempty"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 325c945f..18670914 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -61,6 +61,7 @@ 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 participant Match an existing key to the participant roster inspect enrollment Authenticate an operational enrollment @@ -180,6 +181,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 \ @@ -259,6 +268,15 @@ 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 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 \ @@ -283,6 +301,7 @@ performed. Keep the report outside final/candidate and final/release. "checkpoint": `Usage: mpc-ceremony checkpoint [flags] mpc-ceremony checkpoint [flags] + mpc-ceremony checkpoint inspect-signed-v4 [flags] Legacy storage-first checkpoint operations re-authenticate the exact signed definition, predecessor, both phase chains and all records diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 3e5a12a6..bbfc29aa 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -26,6 +26,17 @@ check by changing the meaning of V3 or a generic "current schema" constant. ## New-version implementation gate +New read-only discovery APIs preserve the old inspection wire shape: +`inspect definition-protocol` authenticates V1–V4 before reporting its exact +format and derived workflow. `checkpoint inspect-signed-v4` authenticates one +V4 pair and returns its predecessor plus bounded stored-verifier dependencies; +it does not authorize progress. The caller must stage and verify the complete +ancestry with `checkpoint verify-stored-v4` before using it. No cumulative +contribution payload inventory is downloaded for this structural check. +Fresh-copy tests cover incident, abort, restart and accepted-contribution +history; removing each required dependency fails verification. The discovery +signature alone intentionally does not prove that an edge is legal. + Current draft: opt-in V4 construction, structural checkpoints and real-artifact verification through final-candidate recording exist in the library. The Linux integration test uses real tiny contributions in both phases, signed custody diff --git a/internal/mpcceremony/checkpoint_v4_discovery.go b/internal/mpcceremony/checkpoint_v4_discovery.go new file mode 100644 index 00000000..2356cea0 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_discovery.go @@ -0,0 +1,55 @@ +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"` +} + +// 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 !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) + } + } + } +} From 47ba50e11022220cb7c18849efb46349898119db Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 07:45:56 +0900 Subject: [PATCH 26/53] Cover discovery command wiring and rejected secret flags --- cmd/mpc-ceremony/integration_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index f9221ae1..65f61241 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -262,6 +262,8 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandCheckpointPrepareV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointSignV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointVerifyStoredV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointInspectSignedV4, Options: CheckpointOptionsV4{}}, + {Command: CommandInspectDefinitionProtocol, Options: InspectDefinitionOptions{}}, {Command: CommandOpsPrepareBundleV4, Options: EvidenceOptionsV4{}}, {Command: CommandOpsSignBundleV4, Options: EvidenceOptionsV4{}}, {Command: CommandReleaseReviewV4, Options: EvidenceOptionsV4{}}, @@ -279,6 +281,8 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { commands := [][]string{ + {"inspect", "definition-protocol"}, + {"checkpoint", "inspect-signed-v4"}, {"init"}, {"identity", "generate"}, {"phase1", "contribute"}, From 26bf46cb10c2601b2ce6dc5c29fde733e336a8fe Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 08:20:20 +0900 Subject: [PATCH 27/53] Expose head-bound turn commitments and batch enrollment guidance --- cmd/mpc-ceremony/checkpoint_command.go | 2 +- cmd/mpc-ceremony/checkpoint_v4.go | 51 ++++-- cmd/mpc-ceremony/checkpoint_v4_test.go | 6 + cmd/mpc-ceremony/evidence_v4_test.go | 32 ++++ cmd/mpc-ceremony/executor.go | 2 +- cmd/mpc-ceremony/integration_test.go | 3 + cmd/mpc-ceremony/main.go | 2 +- cmd/mpc-ceremony/types.go | 2 + cmd/mpc-ceremony/usage.go | 12 ++ docs/ceremony-schema-compatibility.md | 12 ++ .../mpcceremony/checkpoint_v4_commitments.go | 137 ++++++++++++++++ .../checkpoint_v4_commitments_test.go | 152 ++++++++++++++++++ .../mpcceremony/checkpoint_v4_discovery.go | 9 ++ .../checkpoint_v4_enrollment_metadata.go | 77 +++++++++ .../checkpoint_v4_enrollment_metadata_test.go | 78 +++++++++ internal/mpcceremony/checkpoint_v4_files.go | 40 ++++- .../mpcceremony/checkpoint_v4_files_test.go | 11 ++ 17 files changed, 606 insertions(+), 22 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_commitments.go create mode 100644 internal/mpcceremony/checkpoint_v4_commitments_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_enrollment_metadata.go create mode 100644 internal/mpcceremony/checkpoint_v4_enrollment_metadata_test.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 90cbcc3c..60f3fe6f 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -49,7 +49,7 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { options, err := parseEvidenceV4(CommandCheckpointVerifyReleaseV4, args[1:]) invocation.Command, invocation.Options = CommandCheckpointVerifyReleaseV4, options return invocation, wrapCommandError(err, "checkpoint", args[0]) - case "prepare-v4", "sign-v4", "verify-stored-v4", "inspect-signed-v4": + case "prepare-v4", "sign-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]) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index c9ddc733..929208ca 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -18,13 +18,14 @@ type CheckpointOptionsV4 struct { } type CheckpointInspectionV4 struct { - Schema string `json:"schema"` - Depth string `json:"depth"` - Checkpoint m.CheckpointV4 `json:"checkpoint"` - CheckpointRefs m.SignedArtifactRefs `json:"checkpoint_refs"` - ArtifactsVerified bool `json:"artifacts_verified"` - MathematicsReplayed bool `json:"mathematics_replayed"` - GlobalFreshnessVerified bool `json:"global_freshness_verified"` + 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 { @@ -38,12 +39,26 @@ type CheckpointDiscoveryInspectionV4 struct { 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 action == "verify-stored-v4" || action == "inspect-signed-v4" { + if checkpointReadOnlyActionV4(action) { fs.StringVar(&o.CheckpointPath, "checkpoint", "", "exact checkpoint under artifact-root") fs.StringVar(&o.CheckpointSignaturePath, "checkpoint-signature", "", "exact detached checkpoint signature under artifact-root") } else { @@ -60,7 +75,7 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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 action == "verify-stored-v4" || action == "inspect-signed-v4" { + if checkpointReadOnlyActionV4(action) { return o, requireValues(pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath)) } if action != "prepare-v4" && action != "sign-v4" { @@ -111,6 +126,20 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, if err := m.VerifyRunningSoftwareForMode(d.Software, d.Mode); err != nil { return CommandResult{}, err } + 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 { @@ -137,7 +166,7 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, if err != nil { return CommandResult{}, err } - c, err := m.VerifyStoredCheckpointV4(trust, o.ArtifactRoot, refs) + c, commitments, err := m.InspectStoredCheckpointV4(trust, o.ArtifactRoot, refs) if err != nil { return CommandResult{}, err } @@ -146,7 +175,7 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, } 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}}, nil + 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") diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 82203f3a..6a031afa 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -176,6 +176,12 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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) + } // Sign again only after rereading every required byte, not a saved success marker. genesis := filepath.Join(artifactRoot, payload.Name) original := mustReadTestFile(t, genesis) diff --git a/cmd/mpc-ceremony/evidence_v4_test.go b/cmd/mpc-ceremony/evidence_v4_test.go index 4351b40b..267a94e8 100644 --- a/cmd/mpc-ceremony/evidence_v4_test.go +++ b/cmd/mpc-ceremony/evidence_v4_test.go @@ -324,6 +324,38 @@ func TestEvidenceV4CommandsOnRealArtifacts(t *testing.T) { } return result, nil } + 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 { diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 7d0c29af..e131ed04 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -125,7 +125,7 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeSubmissionAccept(invocation.Options.(SubmissionAcceptOptions)) case CommandCheckpointPrepare: return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) - case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4: + case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: return executeCheckpointV4(invocation.Command, invocation.Options.(CheckpointOptionsV4)) case CommandCheckpointSign: return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 65f61241..6b6ca595 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -51,6 +51,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"checkpoint", "sign-v4"}, {"checkpoint", "verify-stored-v4"}, {"checkpoint", "inspect-signed-v4"}, + {"checkpoint", "inspect-enrollments-v4"}, {"checkpoint", "verify-release-v4"}, {"release", "review-v4"}, {"ops", "prepare-bundle-v4"}, @@ -263,6 +264,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandCheckpointSignV4, 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{}}, @@ -283,6 +285,7 @@ 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/main.go b/cmd/mpc-ceremony/main.go index ae2bdad0..a107e3f2 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -271,7 +271,7 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}, "inspect-signed-v4": {}}, + "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}, "inspect-signed-v4": {}, "inspect-enrollments-v4": {}}, "inspect": { "chain": {}, "checkpoint": {}, "checkpoint-transition": {}, "definition": {}, "definition-protocol": {}, "enrollment": {}, "help": {}, "participant": {}, }, diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 4412e764..75c75f20 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -72,6 +72,7 @@ const ( CommandCheckpointSignV4 Command = "checkpoint sign-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" ) @@ -652,6 +653,7 @@ type CommandResult struct { DefinitionInspection *DefinitionInspection `json:"definition_inspection,omitempty"` DefinitionProtocolInspection *DefinitionProtocolInspection `json:"definition_protocol_inspection,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"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 18670914..d1cb5052 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -268,6 +268,17 @@ 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 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 \ @@ -302,6 +313,7 @@ performed. Keep the report outside final/candidate and final/release. mpc-ceremony checkpoint [flags] mpc-ceremony checkpoint [flags] mpc-ceremony checkpoint inspect-signed-v4 [flags] + mpc-ceremony checkpoint inspect-enrollments-v4 [flags] Legacy storage-first checkpoint operations re-authenticate the exact signed definition, predecessor, both phase chains and all records diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index bbfc29aa..3fe89d49 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -37,6 +37,18 @@ Fresh-copy tests cover incident, abort, restart and accepted-contribution history; removing each required dependency fails verification. The discovery signature alone intentionally does not prove that an edge is legal. +The V4 stored projection also includes an ancestry-derived commitment index: +exact enrollment pairs and per-turn outbound history, accepted receipt, accepted +chain/result, and return records. These are locations, not verified payloads. +`checkpoint inspect-enrollments-v4` batch-verifies the exact committed enrollment +set against that head, including proof of possession and the committed disclosure +reference. Its output includes the structural index from the same ancestry walk, +so consumers need not verify that history twice. Discovery labels enrollment +pairs separately from structural dependencies. It does not read disclosure +contents or claim roster completeness. +Outbounds remain newest-first, but a signed receipt may acknowledge an older +valid packet; publication attempts do not change that signed acknowledgement. + Current draft: opt-in V4 construction, structural checkpoints and real-artifact verification through final-candidate recording exist in the library. The Linux integration test uses real tiny contributions in both phases, signed custody diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go new file mode 100644 index 00000000..3d4df97f --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -0,0 +1,137 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "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"` +} + +type OutboundCommitmentV4 struct { + CheckpointSequence uint64 `json:"checkpoint_sequence"` + PublishedAttemptID string `json:"published_attempt_id"` + Pair SignedArtifactRefs `json:"pair"` +} + +type AcceptedTurnRecordV4 struct { + AttemptID string `json:"attempt_id"` + Pair SignedArtifactRefs `json:"pair"` +} + +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"` + // Newest first. PublishedAttemptID records transport history only; a later + // accepted receipt may acknowledge an older still-valid packet in this list. + Outbounds []OutboundCommitmentV4 `json:"outbounds"` + InputReceipt *AcceptedTurnRecordV4 `json:"input_receipt,omitempty"` + AcceptedChain *AcceptedChainCommitmentV4 `json:"accepted_chain,omitempty"` + ReturnHandoff *SignedArtifactRefs `json:"return_handoff,omitempty"` + ReturnReceipt *SignedArtifactRefs `json:"return_receipt,omitempty"` +} + +func collectTurnCommitmentV4(turns map[ContributionScope]*TurnCommitmentV4, c CheckpointV4) error { + t := c.Transition + switch t.Kind { + case CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished, CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted, 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, Outbounds: []OutboundCommitmentV4{}} + turns[scope] = turn + } + switch t.Kind { + case CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished: + if len(turn.Outbounds) >= MaxDeliveryAttemptsPerSubmissionV2 { + return errors.New("outbound commitment index exceeds receipt-attempt limit") + } + turn.Outbounds = append(turn.Outbounds, OutboundCommitmentV4{CheckpointSequence: c.Sequence, PublishedAttemptID: t.AttemptID, Pair: *t.Record}) + case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: + if turn.InputReceipt != nil { + return errors.New("duplicate accepted receipt commitment") + } + turn.InputReceipt = &AcceptedTurnRecordV4{AttemptID: t.AttemptID, Pair: *t.Record} + 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} + base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) + pair := func(name string) (*SignedArtifactRefs, error) { + p := SignedArtifactRefs{} + for _, ref := range t.Evidence { + if ref.Name == base+name+".json" { + p.Record = ref + } + if ref.Name == base+name+".sig" { + p.Signature = ref + } + } + if err := p.Validate(); err != nil { + return nil, err + } + return &p, nil + } + turn.ReturnHandoff, err = pair("return-handoff") + if err != nil { + return err + } + turn.ReturnReceipt, err = pair("return-receipt") + if err != nil { + return err + } + } + 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 c.reader.root.Close() + index, err := checkpointCommitmentsV4(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{}} + 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..51a7d7c1 --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_commitments_test.go @@ -0,0 +1,152 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "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 != 4 || 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.Outbounds) != 1 || turn.Outbounds[0].Pair != *sequence[1].Transition.Record || turn.InputReceipt == nil || turn.InputReceipt.Pair != *sequence[2].Transition.Record || turn.AcceptedChain == nil || turn.AcceptedChain.Pair != *sequence[3].Transition.Record || turn.ReturnReceipt == nil || turn.ReturnHandoff == nil { + t.Fatalf("incomplete turn: %+v", turn) + } + // The index describes commitments only; none of these payloads were needed. + if _, err := os.Stat(filepath.Join(root, roster.Record.Name)); !os.IsNotExist(err) { + t.Fatal("unexpected enrollment bytes") + } +} + +func TestCheckpointCommitmentsRetainAllRetriedOutbounds(t *testing.T) { + d, initial, db, ds := checkpointFixtureV4(t) + template := checkpointTurnV4(t, d, initial, Phase1) + a := template[1] + scope := *a.Transition.Scope + retired := nextCheckpointV4(t, a, CheckpointTransitionV4{Kind: CheckpointDeliveryRetired, Scope: &scope, AttemptID: a.Transition.AttemptID, Evidence: []ArtifactRef{}}) + var err error + retired.Deliveries, err = AdvanceDeliveryV2(a.Deliveries, a.Transition.AttemptID, DeliveryRetired, nil) + if err != nil { + t.Fatal(err) + } + bPair := checkpointSigned("phase1/outbound-new") + bAttempt := strings.Repeat("ab", 16) + b := nextCheckpointV4(t, retired, CheckpointTransitionV4{Kind: CheckpointPhase1OutboundPublished, Scope: &scope, AttemptID: bAttempt, Record: &bPair, Evidence: []ArtifactRef{}}) + b.Deliveries, err = AllocateDeliveryV2(retired.Deliveries, scope, CheckpointSubmissionReceipt, bAttempt) + if err != nil { + t.Fatal(err) + } + receiptTx := template[2].Transition + receiptTx.AttemptID = bAttempt + receipt := nextCheckpointV4(t, b, receiptTx) + receipt.Deliveries, err = AdvanceDeliveryV2(b.Deliveries, bAttempt, DeliveryAccepted, nil) + if err != nil { + t.Fatal(err) + } + receipt.Deliveries, err = AllocateDeliveryV2(receipt.Deliveries, scope, CheckpointSubmissionCandidate, receiptTx.NextAttemptID) + if err != nil { + t.Fatal(err) + } + root := t.TempDir() + trust, head := storeCommitmentSequenceV4(t, d, db, ds, root, []CheckpointV4{initial, a, retired, b, receipt}) + _, index, err := InspectStoredCheckpointV4(trust, root, head) + if err != nil { + t.Fatal(err) + } + turn := index.Turns[0] + if len(turn.Outbounds) != 2 || turn.Outbounds[0].Pair != bPair || turn.Outbounds[1].Pair != *a.Transition.Record || turn.Outbounds[0].CheckpointSequence != 3 || turn.InputReceipt.AttemptID != bAttempt { + t.Fatalf("retry facts lost: %+v", turn) + } + if !reflect.DeepEqual(turn.Scope, scope) { + t.Fatal("turn scope changed") + } + _, repeated, err := InspectStoredCheckpointV4(trust, root, head) + if err != nil || !reflect.DeepEqual(index, repeated) { + t.Fatal("index is not deterministic", err) + } + // Retirement/reallocation alone does not imply a new input packet. + replacement := strings.Repeat("ef", 16) + retired.Transition.NextAttemptID = replacement + retired.Deliveries, err = AllocateDeliveryV2(retired.Deliveries, scope, CheckpointSubmissionReceipt, replacement) + if err != nil { + t.Fatal(err) + } + trust, head = storeCommitmentSequenceV4(t, d, db, ds, root, []CheckpointV4{initial, a, retired}) + _, reallocated, err := InspectStoredCheckpointV4(trust, root, head) + if err != nil || len(reallocated.Turns) != 1 || len(reallocated.Turns[0].Outbounds) != 1 || reallocated.Turns[0].Outbounds[0].Pair != *a.Transition.Record { + t.Fatal("reallocation changed published packet", 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) + if err := collectTurnCommitmentV4(turns, tx); err != nil { + t.Fatal(err) + } + } + if err := collectTurnCommitmentV4(turns, tx); err == nil { + t.Fatal("outbound bound not enforced") + } + turns = map[ContributionScope]*TurnCommitmentV4{} + for _, phase := range []Phase{Phase1, Phase2} { + for n := 1; n <= MaxParticipants; n++ { + scope := *tx.Transition.Scope + scope.Phase = phase + scope.Index = uint8(n) + tx.Transition.Scope = &scope + if err := collectTurnCommitmentV4(turns, tx); err != nil { + t.Fatal(err) + } + } + } + scope := *tx.Transition.Scope + scope.ParticipantID = "extra" + tx.Transition.Scope = &scope + if err := collectTurnCommitmentV4(turns, tx); err == nil { + t.Fatal("turn capacity not enforced") + } +} diff --git a/internal/mpcceremony/checkpoint_v4_discovery.go b/internal/mpcceremony/checkpoint_v4_discovery.go index 2356cea0..12223985 100644 --- a/internal/mpcceremony/checkpoint_v4_discovery.go +++ b/internal/mpcceremony/checkpoint_v4_discovery.go @@ -10,6 +10,8 @@ type CheckpointDiscoveryV4 struct { 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 @@ -21,6 +23,13 @@ func DiscoverSignedCheckpointV4(d CeremonyDefinition, definition, definitionSign 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 } diff --git a/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go b/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go new file mode 100644 index 00000000..ace93718 --- /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 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_files.go b/internal/mpcceremony/checkpoint_v4_files.go index c7051522..3ec40660 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -139,6 +139,7 @@ type checkpointAncestryV4 struct { outbound map[string]SignedArtifactRefs receipts map[ContributionScope]SignedArtifactRefs enrollments []SignedArtifactRefs + enrollmentTransitions []CheckpointTransitionV4 mirrors []SignedArtifactRefs witnesses []SignedArtifactRefs beaconEvidence []SignedArtifactRefs @@ -149,10 +150,11 @@ type checkpointAncestryV4 struct { 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{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}, accepted: map[ContributionScope]SignedArtifactRefs{}, acceptedTransitions: map[ContributionScope]CheckpointTransitionV4{}} + result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}, accepted: map[ContributionScope]SignedArtifactRefs{}, acceptedTransitions: map[ContributionScope]CheckpointTransitionV4{}, turnCommitments: map[ContributionScope]*TurnCommitmentV4{}} var child *CheckpointV4 for { if result.count > MaxCheckpointSequenceV4 { @@ -184,6 +186,9 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } } result.count++ + if err := collectTurnCommitmentV4(result.turnCommitments, current); err != nil { + return checkpointAncestryV4{}, err + } result.checkpoints = append(result.checkpoints, refs) if current.Transition.Kind == CheckpointIncidentRecorded { result.incidents = append(result.incidents, current.Transition) @@ -213,6 +218,7 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } if current.Transition.Kind == CheckpointEnrollmentRecorded { result.enrollments = append(result.enrollments, *current.Transition.Record) + result.enrollmentTransitions = append(result.enrollmentTransitions, current.Transition) } if current.Transition.Kind == CheckpointPhase1ReceiptAccepted || current.Transition.Kind == CheckpointPhase2ReceiptAccepted { result.receipts[*current.Transition.Scope] = *current.Transition.Record @@ -234,28 +240,46 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, // 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) { - trusted, err := LoadSignedDefinition(trust) + c, err := openStoredCheckpointV4(trust, artifactRoot, head) if err != nil { return CheckpointV4{}, err } + defer 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 CheckpointV4{}, err + return nil, err } ds, err := readRegularBounded(trust.DefinitionSignaturePath, 4096) if err != nil { - return CheckpointV4{}, err + return nil, err } reader, err := openCheckpointReaderV4(artifactRoot) if err != nil { - return CheckpointV4{}, err + return nil, err } - defer reader.root.Close() ancestry, err := loadCheckpointAncestryV4(reader, trusted.Definition, db, ds, head) if err != nil { - return CheckpointV4{}, err + reader.root.Close() + return nil, err } - return ancestry.head, nil + return &storedCheckpointContextV4{reader: reader, trusted: trusted, definitionBytes: db, ancestry: ancestry}, nil } // CheckpointPreparationV4 verifies a proposed protocol update before it may be diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 97ea2bc7..60a20cb7 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -326,6 +326,17 @@ func TestReceiptV4FindsCommittedHandoffAfterRetirement(t *testing.T) { 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") } From a32d27df7f6115eaeb5a1397428742b1fd2909ce Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 09:03:24 +0900 Subject: [PATCH 28/53] Inspect retained V4 contribution inventories for safe recovery --- cmd/mpc-ceremony/checkpoint_v4_test.go | 1 + cmd/mpc-ceremony/contribution_inventory_v4.go | 61 ++++ .../contribution_inventory_v4_test.go | 125 ++++++++ cmd/mpc-ceremony/executor.go | 2 + cmd/mpc-ceremony/parse.go | 4 + cmd/mpc-ceremony/types.go | 2 + cmd/mpc-ceremony/usage.go | 15 + docs/trusted-setup-ceremony.md | 16 + internal/mpcceremony/chain.go | 23 +- internal/mpcceremony/checkpoint_v4_files.go | 17 ++ .../mpcceremony/contribution_inventory_v4.go | 214 +++++++++++++ .../contribution_inventory_v4_test.go | 285 ++++++++++++++++++ .../testdata/workflowhelper/checkpoint_v4.go | 27 ++ 13 files changed, 773 insertions(+), 19 deletions(-) create mode 100644 cmd/mpc-ceremony/contribution_inventory_v4.go create mode 100644 cmd/mpc-ceremony/contribution_inventory_v4_test.go create mode 100644 internal/mpcceremony/contribution_inventory_v4.go create mode 100644 internal/mpcceremony/contribution_inventory_v4_test.go diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 6a031afa..d8374204 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -135,6 +135,7 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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) diff --git a/cmd/mpc-ceremony/contribution_inventory_v4.go b/cmd/mpc-ceremony/contribution_inventory_v4.go new file mode 100644 index 00000000..4e13a153 --- /dev/null +++ b/cmd/mpc-ceremony/contribution_inventory_v4.go @@ -0,0 +1,61 @@ +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) { + var o ContributionInventoryOptionsV4 + fs := commandFlagSet("inspect contribution-inventory-v4") + 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) { + trusted, err := loadInspectionCeremony(o.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + if err := m.VerifyRunningSoftwareForMode(trusted.Definition.Software, trusted.Definition.Mode); err != nil { + return CommandResult{}, err + } + raw, err := readRegularOperationalFile(o.ScopePath, 4096) + if err != nil { + return CommandResult{}, err + } + var scope m.ContributionScope + if err := m.UnmarshalCanonical(raw, &scope); err != nil { + return CommandResult{}, err + } + if err := scope.ValidateAssignment(trusted.Definition); 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 +} 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..8a370225 --- /dev/null +++ b/cmd/mpc-ceremony/contribution_inventory_v4_test.go @@ -0,0 +1,125 @@ +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) + sign("erasure", e, 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} + five := runCheckpointCommandExecutable(t, executable, args).ContributionInventoryV4 + if five == nil || five.Inventory.Complete != nil || five.MathematicsReplayed || five.GlobalFreshnessVerified || five.PhysicalErasureVerified || !five.SignaturesVerified || !five.PayloadDigestVerified { + t.Fatalf("wrong CLI boundary %+v", five) + } + files := append([]m.ArtifactRef{}, five.Inventory.Computed.Files...) + for i := range files { + files[i].Name = "phase1/contributions/0001/" + files[i].Name + } + h, err := m.NewTransferHandoff(d, m.Phase1, 1, head, files, p, d.Coordinator, "2026-09-16T00:03:00Z", "2026-09-16T01:03:00Z") + if err != nil { + t.Fatal(err) + } + sign("return-handoff", h, p.KeyID, key) + seven := runCheckpointCommandExecutable(t, executable, args).ContributionInventoryV4 + if seven == nil || seven.Inventory.Complete == nil || seven.Inventory.ComputedCandidateID != five.Inventory.ComputedCandidateID || seven.Inventory.CandidateResultID == five.Inventory.ComputedCandidateID { + t.Fatalf("wrong complete CLI inventory %+v", seven) + } + 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") +} diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index e131ed04..405469dc 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -107,6 +107,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeInspectDefinitionProtocol(invocation.Options.(InspectDefinitionOptions)) case CommandInspectChain: return executeInspectChain(invocation.Options.(InspectChainOptions)) + case CommandInspectContributionInventoryV4: + return executeContributionInventoryV4(invocation.Options.(ContributionInventoryOptionsV4)) case CommandInspectParticipant: return executeInspectParticipant(invocation.Options.(InspectParticipantOptions)) case CommandInspectEnrollment: diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 7a72a025..74f545e9 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -218,6 +218,10 @@ func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, e 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 "definition": options, err := parseInspectDefinition(args[1:]) invocation.Command, invocation.Options = CommandInspectDefinition, options diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 75c75f20..d0ca44f4 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -56,6 +56,7 @@ const ( CommandInspectDefinition Command = "inspect definition" CommandInspectDefinitionProtocol Command = "inspect definition-protocol" CommandInspectChain Command = "inspect chain" + CommandInspectContributionInventoryV4 Command = "inspect contribution-inventory-v4" CommandInspectParticipant Command = "inspect participant" CommandInspectEnrollment Command = "inspect enrollment" CommandInspectCheckpoint Command = "inspect checkpoint" @@ -652,6 +653,7 @@ type CommandResult struct { 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"` CheckpointDiscoveryV4 *CheckpointDiscoveryInspectionV4 `json:"checkpoint_discovery_v4,omitempty"` EnrollmentMetadataV4 *EnrollmentMetadataInspectionV4 `json:"enrollment_metadata_v4,omitempty"` ChainInspection *ChainInspection `json:"chain_inspection,omitempty"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index d1cb5052..18f55db4 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -63,6 +63,7 @@ Commands: 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 participant Match an existing key to the participant roster inspect enrollment Authenticate an operational enrollment inspect checkpoint Authenticate a storage-first workflow checkpoint @@ -196,6 +197,20 @@ backend progress or replay contributions. Failure must not trigger legacy fallba Authenticates the exact canonical ceremony definition against the out-of-band coordinator public key and reports its identity, mode, schedules, and circuit. +`, + "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 \ diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index efb4461e..59ae9a48 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -107,6 +107,22 @@ 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. +## Experimental V4 retained-candidate inspection + +`inspect contribution-inventory-v4` reconstructs a retained contribution from +its exact signed predecessor and expected turn. It returns a five-file inventory +after computation and cleanup attestation, and a distinct seven-file inventory +after the signed return packet is complete. Partial or inconsistent return +packets are errors; they must not trigger another computation. Only the final +seven-file identity is used for candidate delivery/acceptance comparisons. + +This read-only inspection checks signatures, file hashes and locally checkable +chronology—not contribution mathematics, backend freshness, physical erasure or +acceptance. 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/chain.go b/internal/mpcceremony/chain.go index 5d8a95f9..3371b735 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") diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 3ec40660..d9bcd350 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -696,12 +696,29 @@ func verifyCandidateChainInventoryV4(last ChainRecord, scope ContributionScope, } func verifyReturnHandoffV4(reader *checkpointReaderV4, d CeremonyDefinition, scope ContributionScope, inventory CandidateInventory) error { + if err := inventory.Validate(); err != nil { + return err + } + if len(inventory.Files) != 7 || inventory.Scope != scope { + return errors.New("return handoff requires the complete seven-file inventory for this scope") + } base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) refs := SignedArtifactRefs{Record: ArtifactRef{Name: base + inventory.Files[5].Name, Digest: inventory.Files[5].Digest}, Signature: ArtifactRef{Name: base + inventory.Files[6].Name, Digest: inventory.Files[6].Digest}} record, signature, err := reader.pair(refs) if err != nil { return err } + return verifyReturnHandoffBytesV4(d, scope, inventory, record, signature) +} + +func verifyReturnHandoffBytesV4(d CeremonyDefinition, scope ContributionScope, inventory CandidateInventory, record, signature []byte) error { + if err := inventory.Validate(); err != nil { + return err + } + if len(inventory.Files) != 7 || inventory.Scope != scope || NewDigest(record) != inventory.Files[5].Digest || NewDigest(signature) != inventory.Files[6].Digest { + return errors.New("return handoff differs from the complete candidate inventory") + } + base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) participant, ok := d.ParticipantByID(scope.ParticipantID) if !ok { return errors.New("return sender is not in roster") diff --git a/internal/mpcceremony/contribution_inventory_v4.go b/internal/mpcceremony/contribution_inventory_v4.go new file mode 100644 index 00000000..4d870705 --- /dev/null +++ b/internal/mpcceremony/contribution_inventory_v4.go @@ -0,0 +1,214 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "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 five-file computation and, +// if present, its seven-file return package together. 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 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 + names := []string{"attestation.json", "attestation.sig", "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 attestation ContributionAttestation + if err := VerifySignedRecord(data["attestation.json"], data["attestation.sig"], &attestation, participant.Identity.KeyID, key); 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, err + } + if err := ValidateErasureForContribution(attestation, erasure); err != nil { + return zero, err + } + previous, err := chain.HeadPayload() + if err != nil { + return zero, 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, errors.New("candidate attestation differs from the exact expected predecessor and participant") + } + if err := validateAttestationSoftwareBinding(d, attestation); err != nil { + return zero, err + } + if err := validateContributionChronology(d, chain, attestation); err != nil { + return zero, err + } + output := attestation.OutputPayload + output.Name = "contribution.bin" + if _, err := r.read(output, MaxArtifactSize, false); err != nil { + return zero, err + } + computed := CandidateInventory{Schema: CandidateInventorySchemaV1, Scope: scope, Files: []ArtifactRef{ + {Name: "attestation.json", Digest: NewDigest(data["attestation.json"])}, + {Name: "attestation.sig", Digest: NewDigest(data["attestation.sig"])}, output, + {Name: "erasure.json", Digest: NewDigest(data["erasure.json"])}, + {Name: "erasure.sig", Digest: NewDigest(data["erasure.sig"])}, + }} + id, err := computed.ID() + if err != nil { + return zero, err + } + result := ContributionInventoryInspectionV4{Scope: scope, Computed: computed, ComputedCandidateID: id} + record, recordErr := readLocalInventoryRecordV4(r, "return-handoff.json", maxSignedRecordBytes) + sig, sigErr := readLocalInventoryRecordV4(r, "return-handoff.sig", 4096) + if errors.Is(recordErr, os.ErrNotExist) && errors.Is(sigErr, os.ErrNotExist) { + return result, nil + } + if recordErr != nil { + return zero, fmt.Errorf("return handoff: %w", recordErr) + } + if sigErr != nil { + return zero, fmt.Errorf("return handoff signature: %w", sigErr) + } + complete := CandidateInventory{Schema: computed.Schema, Scope: scope, Files: append(append([]ArtifactRef{}, computed.Files...), ArtifactRef{Name: "return-handoff.json", Digest: NewDigest(record)}, ArtifactRef{Name: "return-handoff.sig", Digest: NewDigest(sig)})} + if err := verifyReturnHandoffBytesV4(d, scope, complete, record, sig); err != nil { + return zero, err + } + var handoff TransferHandoff + if err := UnmarshalCanonical(record, &handoff); err != nil { + return zero, err + } + destroyed, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt) + created, _ := time.Parse(time.RFC3339Nano, handoff.CreatedAt) + if !created.After(destroyed) { + return zero, errors.New("return handoff must be created strictly after cleanup") + } + result.CandidateResultID, err = complete.ID() + if err != nil { + return zero, err + } + result.Complete = &complete + return result, 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..71196918 --- /dev/null +++ b/internal/mpcceremony/contribution_inventory_v4_test.go @@ -0,0 +1,285 @@ +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 (f inventoryFixtureV4) returnPair(t *testing.T, inventory CandidateInventory) SignedArtifactRefs { + t.Helper() + files := append([]ArtifactRef{}, inventory.Files...) + for i := range files { + files[i].Name = fmt.Sprintf("%s/contributions/%04d/%s", f.scope.Phase, f.scope.Index, files[i].Name) + } + p := f.d.Roster[int(f.scope.Index)-1].Identity + h, err := NewTransferHandoff(f.d, f.scope.Phase, f.scope.Index, f.scope.ParentHeadID, files, p, f.d.Coordinator, "2026-07-23T12:03:00Z", "2026-07-23T13:03:00Z") + if err != nil { + t.Fatal(err) + } + return putCheckpointTestPairV4(t, f.dir, "return-handoff", h, p.KeyID, adversarialPrivateKey(0x10+f.scope.Index)) +} + +func TestContributionInventoryV4ReconstructsFiveAndSevenFiles(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.Scope != f.scope { + t.Fatalf("bad computed result %+v", five) + } + f.returnPair(t, five.Computed) + seven, err := f.inspect() + if err != nil { + t.Fatal(err) + } + if seven.Complete == nil || len(seven.Complete.Files) != 7 || seven.ComputedCandidateID != five.ComputedCandidateID || seven.CandidateResultID == "" || seven.CandidateResultID == five.ComputedCandidateID { + t.Fatalf("bad complete result %+v", seven) + } + putCheckpointTestFileV4(t, f.dir, "local-metadata.json", []byte("not uploaded")) + again, err := f.inspect() + if err != nil || again.CandidateResultID != seven.CandidateResultID { + t.Fatal("extra file changed inventory", err) + } + }) + } +} + +func TestContributionInventoryV4RejectsPartialChangedAndUnboundWork(t *testing.T) { + for _, test := range []string{"scope", "phase", "participant", "software", "time", "payload", "partial", "return-signature", "changed-five", "symlink", "oversize"} { + t.Run(test, func(t *testing.T) { + f := localInventoryFixtureV4(t, Phase1) + five, 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 "partial": + putCheckpointTestFileV4(t, f.dir, "return-handoff.json", []byte("{}")) + case "return-signature": + f.returnPair(t, five.Computed) + putCheckpointTestFileV4(t, f.dir, "return-handoff.sig", []byte("{}")) + case "changed-five": + f.returnPair(t, five.Computed) + a := f.a + a.ContributedAt = "2026-07-23T12:01:01Z" + f.sign(t, a) + 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 TestReturnHandoffV4RejectsFiveFileInventoryWithoutPanic(t *testing.T) { + f := localInventoryFixtureV4(t, Phase1) + i, err := f.inspect() + if err != nil { + t.Fatal(err) + } + if err := verifyReturnHandoffBytesV4(f.d, f.scope, i.Computed, nil, nil); err == nil { + t.Fatal("accepted incomplete inventory") + } +} + +func TestContributionInventoryV4ReturnMustFollowCleanup(t *testing.T) { + for _, phase := range []Phase{Phase1, Phase2} { + for _, created := range []string{"2026-07-23T12:01:59Z", "2026-07-23T12:02:00Z"} { + t.Run(string(phase)+created, func(t *testing.T) { + f := localInventoryFixtureV4(t, phase) + five, err := f.inspect() + if err != nil { + t.Fatal(err) + } + pair := f.returnPair(t, five.Computed) + b, err := os.ReadFile(filepath.Join(f.dir, pair.Record.Name)) + if err != nil { + t.Fatal(err) + } + var h TransferHandoff + if err := UnmarshalCanonical(b, &h); err != nil { + t.Fatal(err) + } + h.CreatedAt = created + putCheckpointTestPairV4(t, f.dir, "return-handoff", h, f.d.Roster[0].Identity.KeyID, adversarialPrivateKey(0x11)) + if _, err := f.inspect(); err == nil || !strings.Contains(err.Error(), "strictly after cleanup") { + t.Fatal(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) + } + f.returnPair(t, i.Computed) + if _, err := f.inspect(); err != nil { + t.Fatal(err) + } + 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/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 0e85b77b..ee413b2a 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -3,6 +3,7 @@ package main import ( "bytes" "crypto/ed25519" + "errors" "fmt" "os" "path/filepath" @@ -217,6 +218,13 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com return err } returnFiles := []m.ArtifactRef{} + computedInventory, err := m.InspectContributionInventoryV4(trust, paths, scope, candidateDir) + if err != nil { + return err + } + if computedInventory.Complete != nil || computedInventory.ComputedCandidateID == "" { + return errors.New("computed inventory reconstruction failed") + } 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 { @@ -244,6 +252,22 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if err != nil { return err } + for _, ref := range []m.ArtifactRef{returnRefs.Record, returnRefs.Signature} { + b, err := os.ReadFile(filepath.Join(root, ref.Name)) + if err != nil { + return err + } + if err := os.WriteFile(filepath.Join(candidateDir, filepath.Base(ref.Name)), b, 0600); err != nil { + return err + } + } + completeInventory, err := m.InspectContributionInventoryV4(trust, paths, scope, candidateDir) + if err != nil { + return err + } + if completeInventory.Complete == nil || completeInventory.ComputedCandidateID != computedInventory.ComputedCandidateID || completeInventory.CandidateResultID == computedInventory.ComputedCandidateID { + return errors.New("complete inventory reconstruction failed") + } accepted, err := m.VerifyAndAcceptContribution(m.AcceptContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase1, Transcript: paths, CandidateDir: candidateDir, CoordinatorPrivateKeyPath: coordinatorPath, AcceptedAt: "2023-08-23T15:05:00Z"}) if err != nil { return err @@ -275,6 +299,9 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com for i := range inventory.Files { inventory.Files[i].Name = filepath.Base(inventory.Files[i].Name) } + if id, err := inventory.ID(); err != nil || id != completeInventory.CandidateResultID { + return errors.New("accepted inventory differs from inspected candidate") + } evidence := append(append([]m.ArtifactRef{}, files...), returnEvidence[2:]...) evidence = append(evidence, last.Verification) next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1CandidateAccepted, Scope: &scope, AttemptID: candidateAttempt, Record: &chainRefs, Evidence: sorted(evidence), Contribution: &inventory}) From 897fdb5c51c3057b0b3641745b7750cc7a16c5ed Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 10:53:54 +0900 Subject: [PATCH 29/53] Inspect generated V4 contribution files before cleanup signing --- cmd/mpc-ceremony/computation_output_v4.go | 27 ++++++ cmd/mpc-ceremony/contribution_inventory_v4.go | 36 +++++--- .../contribution_inventory_v4_test.go | 10 +- cmd/mpc-ceremony/executor.go | 2 + cmd/mpc-ceremony/parse.go | 4 + cmd/mpc-ceremony/types.go | 2 + cmd/mpc-ceremony/usage.go | 14 +++ docs/trusted-setup-ceremony.md | 8 ++ internal/mpcceremony/computation_output_v4.go | 92 +++++++++++++++++++ .../mpcceremony/computation_output_v4_test.go | 73 +++++++++++++++ .../mpcceremony/contribution_inventory_v4.go | 37 ++------ .../testdata/workflowhelper/checkpoint_v4.go | 7 ++ 12 files changed, 270 insertions(+), 42 deletions(-) create mode 100644 cmd/mpc-ceremony/computation_output_v4.go create mode 100644 internal/mpcceremony/computation_output_v4.go create mode 100644 internal/mpcceremony/computation_output_v4_test.go 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 index 4e13a153..83b0b49c 100644 --- a/cmd/mpc-ceremony/contribution_inventory_v4.go +++ b/cmd/mpc-ceremony/contribution_inventory_v4.go @@ -20,8 +20,12 @@ type ContributionInventoryInspectionV4 struct { } 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 contribution-inventory-v4") + 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") @@ -35,27 +39,35 @@ func parseContributionInventoryV4(args []string) (ContributionInventoryOptionsV4 } func executeContributionInventoryV4(o ContributionInventoryOptionsV4) (CommandResult, error) { - trusted, err := loadInspectionCeremony(o.InspectDefinitionOptions) + scope, err := expectedContributionInspectionScopeV4(o) if err != nil { return CommandResult{}, err } - if err := m.VerifyRunningSoftwareForMode(trusted.Definition.Software, trusted.Definition.Mode); err != nil { + 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 CommandResult{}, err + return scope, err } - var scope m.ContributionScope if err := m.UnmarshalCanonical(raw, &scope); err != nil { - return CommandResult{}, err + return scope, err } if err := scope.ValidateAssignment(trusted.Definition); 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 scope, 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 + return scope, nil } diff --git a/cmd/mpc-ceremony/contribution_inventory_v4_test.go b/cmd/mpc-ceremony/contribution_inventory_v4_test.go index 8a370225..6c1b19d3 100644 --- a/cmd/mpc-ceremony/contribution_inventory_v4_test.go +++ b/cmd/mpc-ceremony/contribution_inventory_v4_test.go @@ -76,7 +76,6 @@ func checkContributionInventoryExecutableV4(t *testing.T, executable, root strin } write("contribution.bin", payload) sign("attestation", a, p.KeyID, key) - sign("erasure", e, 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 { @@ -84,6 +83,13 @@ func checkContributionInventoryExecutableV4(t *testing.T, executable, root strin } 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.MathematicsReplayed || five.GlobalFreshnessVerified || five.PhysicalErasureVerified || !five.SignaturesVerified || !five.PayloadDigestVerified { t.Fatalf("wrong CLI boundary %+v", five) @@ -122,4 +128,6 @@ func checkContributionInventoryExecutableV4(t *testing.T, executable, root strin } } assertCheckpointExecutableFails(t, executable, badArgs, "binary") + badArgs[3] = "computation-output-v4" + assertCheckpointExecutableFails(t, executable, badArgs, "binary") } diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 405469dc..4b63e22c 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -109,6 +109,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com 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: diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 74f545e9..56c20d33 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -222,6 +222,10 @@ func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, e 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 diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index d0ca44f4..8378cac4 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -57,6 +57,7 @@ const ( 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" @@ -654,6 +655,7 @@ type CommandResult struct { 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"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 18f55db4..1c9ba385 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -64,6 +64,7 @@ Commands: 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 @@ -197,6 +198,19 @@ backend progress or replay contributions. Failure must not trigger legacy fallba 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 \ diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index 59ae9a48..58d8f995 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -109,6 +109,14 @@ in-progress ceremony through a changed verifier policy. ## 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 a five-file inventory after computation and cleanup attestation, and a distinct seven-file inventory diff --git a/internal/mpcceremony/computation_output_v4.go b/internal/mpcceremony/computation_output_v4.go new file mode 100644 index 00000000..188e1b6c --- /dev/null +++ b/internal/mpcceremony/computation_output_v4.go @@ -0,0 +1,92 @@ +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 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, 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, 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, errors.New("candidate attestation differs from the exact expected predecessor and participant") + } + if err := validateAttestationSoftwareBinding(d, attestation); err != nil { + return zero, attestation, err + } + if err := validateContributionChronology(d, chain, attestation); err != nil { + return zero, attestation, err + } + output := attestation.OutputPayload + output.Name = "contribution.bin" + if _, err := r.read(output, MaxArtifactSize, false); err != nil { + 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_inventory_v4.go b/internal/mpcceremony/contribution_inventory_v4.go index 4d870705..67c81878 100644 --- a/internal/mpcceremony/contribution_inventory_v4.go +++ b/internal/mpcceremony/contribution_inventory_v4.go @@ -62,7 +62,11 @@ func InspectContributionInventoryV4(trust TrustPaths, predecessor PhaseTranscrip func inspectContributionInventoryV4(r *checkpointReaderV4, d CeremonyDefinition, chain Chain, scope ContributionScope) (ContributionInventoryInspectionV4, error) { var zero ContributionInventoryInspectionV4 - names := []string{"attestation.json", "attestation.sig", "erasure.json", "erasure.sig"} + 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) @@ -83,10 +87,6 @@ func inspectContributionInventoryV4(r *checkpointReaderV4, d CeremonyDefinition, if err != nil { return zero, err } - var attestation ContributionAttestation - if err := VerifySignedRecord(data["attestation.json"], data["attestation.sig"], &attestation, participant.Identity.KeyID, key); 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, err @@ -94,30 +94,9 @@ func inspectContributionInventoryV4(r *checkpointReaderV4, d CeremonyDefinition, if err := ValidateErasureForContribution(attestation, erasure); err != nil { return zero, err } - previous, err := chain.HeadPayload() - if err != nil { - return zero, 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, errors.New("candidate attestation differs from the exact expected predecessor and participant") - } - if err := validateAttestationSoftwareBinding(d, attestation); err != nil { - return zero, err - } - if err := validateContributionChronology(d, chain, attestation); err != nil { - return zero, err - } - output := attestation.OutputPayload - output.Name = "contribution.bin" - if _, err := r.read(output, MaxArtifactSize, false); err != nil { - return zero, err - } - computed := CandidateInventory{Schema: CandidateInventorySchemaV1, Scope: scope, Files: []ArtifactRef{ - {Name: "attestation.json", Digest: NewDigest(data["attestation.json"])}, - {Name: "attestation.sig", Digest: NewDigest(data["attestation.sig"])}, output, - {Name: "erasure.json", Digest: NewDigest(data["erasure.json"])}, - {Name: "erasure.sig", Digest: NewDigest(data["erasure.sig"])}, - }} + 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 diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index ee413b2a..60e6a27c 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -214,6 +214,13 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if _, err = m.CreateContributionCandidate(m.ContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase1, Transcript: paths, ParticipantID: p.ID, 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 } From 390c5a527b515efb07825d3189781c2c46868f88 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 11:16:55 +0900 Subject: [PATCH 30/53] Expose exact authenticated definition references for workflow binding --- cmd/mpc-ceremony/definition_protocol.go | 2 ++ cmd/mpc-ceremony/definition_protocol_test.go | 24 ++++++++++++++++++++ internal/mpcceremony/workflow.go | 7 +++++- 3 files changed, 32 insertions(+), 1 deletion(-) diff --git a/cmd/mpc-ceremony/definition_protocol.go b/cmd/mpc-ceremony/definition_protocol.go index 8461415f..22cb93c2 100644 --- a/cmd/mpc-ceremony/definition_protocol.go +++ b/cmd/mpc-ceremony/definition_protocol.go @@ -11,6 +11,7 @@ type DefinitionProtocolInspection struct { 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) { @@ -27,6 +28,7 @@ func executeInspectDefinitionProtocol(o InspectDefinitionOptions) (CommandResult 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 index 1bced5e0..6a624372 100644 --- a/cmd/mpc-ceremony/definition_protocol_test.go +++ b/cmd/mpc-ceremony/definition_protocol_test.go @@ -3,9 +3,14 @@ package main import ( "bytes" "context" + "crypto/sha256" "encoding/json" + "fmt" + "os" "testing" + "golang.org/x/crypto/blake2b" + m "proof-tool/internal/mpcceremony" ) @@ -47,6 +52,25 @@ func TestDefinitionProtocolAuthenticatedDispatch(t *testing.T) { 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...) diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 5772484f..6615d620 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 } From bc64ab886a83e44c2a48250946a20e08685c7193 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 12:59:03 +0900 Subject: [PATCH 31/53] Simplify storage-first ceremony turns --- cmd/mpc-ceremony/atomic_output.go | 60 +++ cmd/mpc-ceremony/atomic_output_test.go | 36 ++ cmd/mpc-ceremony/checkpoint_command.go | 2 +- cmd/mpc-ceremony/checkpoint_v4.go | 90 +++- cmd/mpc-ceremony/checkpoint_v4_test.go | 2 +- .../contribution_inventory_v4_test.go | 15 +- cmd/mpc-ceremony/evidence_v4_test.go | 2 +- cmd/mpc-ceremony/executor.go | 64 ++- cmd/mpc-ceremony/integration_test.go | 4 + cmd/mpc-ceremony/main.go | 2 +- cmd/mpc-ceremony/parse.go | 11 +- cmd/mpc-ceremony/submission_command.go | 447 ------------------ cmd/mpc-ceremony/submission_command_test.go | 245 ---------- cmd/mpc-ceremony/types.go | 29 +- cmd/mpc-ceremony/usage.go | 76 +-- docs/ceremony-custody-workflow.md | 9 +- docs/ceremony-schema-compatibility.md | 289 ++++------- docs/mpc-ceremony-release.md | 6 +- docs/trusted-setup-ceremony.md | 13 +- internal/mpcceremony/checkpoint_v4.go | 103 ++-- internal/mpcceremony/checkpoint_v4_bundle.go | 30 +- .../mpcceremony/checkpoint_v4_commitments.go | 64 +-- .../checkpoint_v4_commitments_test.go | 78 +-- internal/mpcceremony/checkpoint_v4_files.go | 130 ++--- .../checkpoint_v4_governance_test.go | 4 +- internal/mpcceremony/checkpoint_v4_test.go | 88 ++-- internal/mpcceremony/checkpoint_v4_turn.go | 255 ++++++++++ .../mpcceremony/contribution_allocation_v4.go | 84 ++++ .../mpcceremony/contribution_inventory_v4.go | 37 +- .../contribution_inventory_v4_test.go | 86 +--- internal/mpcceremony/delivery_scope.go | 7 +- internal/mpcceremony/delivery_scope_test.go | 4 +- internal/mpcceremony/operational_bundle.go | 337 ++++++------- .../testdata/workflowhelper/checkpoint_v4.go | 175 +------ .../workflowhelper/checkpoint_v4_final.go | 88 +--- .../workflowhelper/checkpoint_v4_review.go | 2 +- internal/mpcceremony/workflow.go | 23 + 37 files changed, 1155 insertions(+), 1842 deletions(-) create mode 100644 cmd/mpc-ceremony/atomic_output.go create mode 100644 cmd/mpc-ceremony/atomic_output_test.go delete mode 100644 cmd/mpc-ceremony/submission_command.go delete mode 100644 cmd/mpc-ceremony/submission_command_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_turn.go create mode 100644 internal/mpcceremony/contribution_allocation_v4.go diff --git a/cmd/mpc-ceremony/atomic_output.go b/cmd/mpc-ceremony/atomic_output.go new file mode 100644 index 00000000..b10c8a09 --- /dev/null +++ b/cmd/mpc-ceremony/atomic_output.go @@ -0,0 +1,60 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "slices" +) + +// writeAtomicOutputDir publishes a small closed set of result files with a +// no-replace directory rename. A byte-identical completed result is an +// idempotent success; incomplete or conflicting output is never overwritten. +func writeAtomicOutputDir(outDir string, files map[string][]byte) (err error) { + parent := filepath.Dir(outDir) + if err := os.MkdirAll(parent, 0o700); err != nil { + return err + } + if _, err := os.Lstat(outDir); err == nil { + info, statErr := os.Lstat(outDir) + if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.IsDir() { + return errors.New("atomic output path exists but is not a regular directory") + } + for name, expected := range files { + actual, readErr := readRegularOperationalFile(filepath.Join(outDir, name), maxOperationalRecordBytes) + if readErr != nil || !slices.Equal(actual, expected) { + return errors.New("atomic output already exists with conflicting or incomplete contents") + } + } + entries, readErr := os.ReadDir(outDir) + if readErr != nil || len(entries) != len(files) { + return errors.New("atomic output already exists with conflicting or incomplete contents") + } + return nil + } else if !os.IsNotExist(err) { + return err + } + tmp, err := os.MkdirTemp(parent, ".atomic-output-*") + if err != nil { + return err + } + complete := false + defer func() { + if !complete { + _ = os.RemoveAll(tmp) + } + }() + for name, data := range files { + if err := writeFreshOperationalFile(filepath.Join(tmp, name), data, 0o600); err != nil { + return err + } + } + if err := syncDirectory(tmp); err != nil { + return err + } + if err := renameDirectoryNoReplace(tmp, outDir); err != nil { + return err + } + complete = true + return syncDirectory(parent) +} diff --git a/cmd/mpc-ceremony/atomic_output_test.go b/cmd/mpc-ceremony/atomic_output_test.go new file mode 100644 index 00000000..f6a77f14 --- /dev/null +++ b/cmd/mpc-ceremony/atomic_output_test.go @@ -0,0 +1,36 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestAtomicOutputDirectoryIsIdempotentAndClosed(t *testing.T) { + out := filepath.Join(t.TempDir(), "result") + files := map[string][]byte{"checkpoint.json": []byte("checkpoint"), "checkpoint.sig": []byte("signature")} + if err := writeAtomicOutputDir(out, files); err != nil { + t.Fatal(err) + } + if err := writeAtomicOutputDir(out, files); err != nil { + t.Fatalf("byte-identical retry: %v", err) + } + if err := os.WriteFile(filepath.Join(out, "checkpoint.sig"), []byte("changed"), 0o600); err != nil { + t.Fatal(err) + } + if err := writeAtomicOutputDir(out, files); err == nil || !strings.Contains(err.Error(), "conflicting or incomplete") { + t.Fatalf("conflicting retry = %v", err) + } +} + +func TestAtomicOutputFailureDoesNotPublishDirectory(t *testing.T) { + parent := t.TempDir() + out := filepath.Join(parent, "result") + if err := writeAtomicOutputDir(out, map[string][]byte{"nested/file": []byte("invalid")}); err == nil { + t.Fatal("invalid nested output succeeded") + } + if _, err := os.Lstat(out); !os.IsNotExist(err) { + t.Fatalf("failed output was published: %v", err) + } +} diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 60f3fe6f..7ee364ae 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -49,7 +49,7 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { options, err := parseEvidenceV4(CommandCheckpointVerifyReleaseV4, args[1:]) invocation.Command, invocation.Options = CommandCheckpointVerifyReleaseV4, options return invocation, wrapCommandError(err, "checkpoint", args[0]) - case "prepare-v4", "sign-v4", "verify-stored-v4", "inspect-signed-v4", "inspect-enrollments-v4": + case "prepare-v4", "sign-v4", "allocate-v4", "accept-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]) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index 929208ca..014fbd22 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -14,7 +14,8 @@ type CheckpointOptionsV4 struct { InspectDefinitionOptions ArtifactRoot, ProposalPath, RejectedCandidateDir string CheckpointPath, CheckpointSignaturePath string - CoordinatorSigningKey, OutPath string + CoordinatorSigningKey, OutPath, OutDir string + AttemptID, AllocatedAt, AcceptedAt, CandidateDir string } type CheckpointInspectionV4 struct { @@ -58,9 +59,21 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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) { + if checkpointReadOnlyActionV4(action) || action == "allocate-v4" || action == "accept-candidate-v4" { 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 == "allocate-v4" || action == "accept-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") + } } 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") @@ -78,6 +91,15 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error if checkpointReadOnlyActionV4(action) { return o, requireValues(pathValue("--checkpoint", o.CheckpointPath), pathValue("--checkpoint-signature", o.CheckpointSignaturePath)) } + if action == "allocate-v4" || action == "accept-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)) + } + 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") } @@ -100,8 +122,7 @@ func checkpointNeedsCircuitV4(kind m.CheckpointTransitionKind) (bool, error) { case m.CheckpointInitial, m.CheckpointPhase1CandidateAccepted, m.CheckpointPhase2CandidateAccepted, m.CheckpointPhase1Sealed, m.CheckpointPhase2Initialized, m.CheckpointFinalCandidateRecorded: return true, nil - case m.CheckpointPhase1OutboundPublished, m.CheckpointPhase2OutboundPublished, - m.CheckpointPhase1ReceiptAccepted, m.CheckpointPhase2ReceiptAccepted, + case m.CheckpointPhase1CandidateAllocated, m.CheckpointPhase2CandidateAllocated, m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, @@ -126,6 +147,55 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, if err := m.VerifyRunningSoftwareForMode(d.Software, d.Mode); err != nil { return CommandResult{}, err } + if command == CommandCheckpointAllocateV4 || command == CommandCheckpointAcceptCandidateV4 { + _, _, 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 + if command == 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 + } else { + 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 + } + 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 := "allocated the exact next candidate turn" + if command == CommandCheckpointAcceptCandidateV4 { + action = "verified and accepted the exact allocated candidate" + } + 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 { @@ -254,6 +324,18 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, 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"} { diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index d8374204..0c30ec47 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -21,7 +21,7 @@ func TestCheckpointV4CircuitClassification(t *testing.T) { kinds []m.CheckpointTransitionKind }{ {true, []m.CheckpointTransitionKind{m.CheckpointInitial, m.CheckpointPhase1CandidateAccepted, m.CheckpointPhase2CandidateAccepted, m.CheckpointPhase1Sealed, m.CheckpointPhase2Initialized, m.CheckpointFinalCandidateRecorded}}, - {false, []m.CheckpointTransitionKind{m.CheckpointPhase1OutboundPublished, m.CheckpointPhase2OutboundPublished, m.CheckpointPhase1ReceiptAccepted, m.CheckpointPhase2ReceiptAccepted, m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, m.CheckpointWitnessRecorded, m.CheckpointBeaconEvidenceRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted}}, + {false, []m.CheckpointTransitionKind{m.CheckpointPhase1CandidateAllocated, m.CheckpointPhase2CandidateAllocated, m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, m.CheckpointWitnessRecorded, m.CheckpointBeaconEvidenceRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted}}, } { for _, kind := range tc.kinds { if got, err := checkpointNeedsCircuitV4(kind); err != nil || got != tc.math { diff --git a/cmd/mpc-ceremony/contribution_inventory_v4_test.go b/cmd/mpc-ceremony/contribution_inventory_v4_test.go index 6c1b19d3..26cb1e7a 100644 --- a/cmd/mpc-ceremony/contribution_inventory_v4_test.go +++ b/cmd/mpc-ceremony/contribution_inventory_v4_test.go @@ -91,22 +91,9 @@ func checkContributionInventoryExecutableV4(t *testing.T, executable, root strin } sign("erasure", e, p.KeyID, key) five := runCheckpointCommandExecutable(t, executable, args).ContributionInventoryV4 - if five == nil || five.Inventory.Complete != nil || five.MathematicsReplayed || five.GlobalFreshnessVerified || five.PhysicalErasureVerified || !five.SignaturesVerified || !five.PayloadDigestVerified { + 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) } - files := append([]m.ArtifactRef{}, five.Inventory.Computed.Files...) - for i := range files { - files[i].Name = "phase1/contributions/0001/" + files[i].Name - } - h, err := m.NewTransferHandoff(d, m.Phase1, 1, head, files, p, d.Coordinator, "2026-09-16T00:03:00Z", "2026-09-16T01:03:00Z") - if err != nil { - t.Fatal(err) - } - sign("return-handoff", h, p.KeyID, key) - seven := runCheckpointCommandExecutable(t, executable, args).ContributionInventoryV4 - if seven == nil || seven.Inventory.Complete == nil || seven.Inventory.ComputedCandidateID != five.Inventory.ComputedCandidateID || seven.Inventory.CandidateResultID == five.Inventory.ComputedCandidateID { - t.Fatalf("wrong complete CLI inventory %+v", seven) - } bad := d bad.Software.ToolBinary = m.NewDigest([]byte("unapproved executable")) bad.Software.Binaries = append([]m.SoftwareBinary{}, d.Software.Binaries...) diff --git a/cmd/mpc-ceremony/evidence_v4_test.go b/cmd/mpc-ceremony/evidence_v4_test.go index 267a94e8..6475d0a5 100644 --- a/cmd/mpc-ceremony/evidence_v4_test.go +++ b/cmd/mpc-ceremony/evidence_v4_test.go @@ -409,7 +409,7 @@ func TestEvidenceV4CommandsOnRealArtifacts(t *testing.T) { 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].ReturnReceipt.Record.Name) + 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") diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 4b63e22c..2c08b608 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -123,13 +123,9 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeInspectSubmission(invocation.Options.(InspectSubmissionOptions)) case CommandInspectSubmissionAcknowledgement: return executeInspectSubmissionAcknowledgement(invocation.Options.(InspectSubmissionAcknowledgementOptions)) - case CommandSubmissionSign: - return executeSubmissionSign(invocation.Options.(SubmissionSignOptions)) - case CommandSubmissionAccept: - return executeSubmissionAccept(invocation.Options.(SubmissionAcceptOptions)) case CommandCheckpointPrepare: return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) - case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: + case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointAllocateV4, CommandCheckpointAcceptCandidateV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: return executeCheckpointV4(invocation.Command, invocation.Options.(CheckpointOptionsV4)) case CommandCheckpointSign: return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) @@ -241,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 } @@ -249,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 } diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 6b6ca595..6e09271a 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -49,6 +49,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"decision", "verify"}, {"checkpoint", "prepare-v4"}, {"checkpoint", "sign-v4"}, + {"checkpoint", "allocate-v4"}, + {"checkpoint", "accept-candidate-v4"}, {"checkpoint", "verify-stored-v4"}, {"checkpoint", "inspect-signed-v4"}, {"checkpoint", "inspect-enrollments-v4"}, @@ -217,6 +219,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--witness-enrollment", "--witness-enrollment-signature", "--accepted-at", + "--allocated-at", + "--attempt-id", "--allowed-binary", "--contributed-at", "--disable-optional-assurance", diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index a107e3f2..783d97a6 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -271,7 +271,7 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}, "inspect-signed-v4": {}, "inspect-enrollments-v4": {}}, + "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "allocate-v4": {}, "accept-candidate-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}, "inspect-signed-v4": {}, "inspect-enrollments-v4": {}}, "inspect": { "chain": {}, "checkpoint": {}, "checkpoint-transition": {}, "definition": {}, "definition-protocol": {}, "enrollment": {}, "help": {}, "participant": {}, }, diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 56c20d33..cf88fc1a 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -101,13 +101,6 @@ func parseInvocation(args []string) (Invocation, error) { return parseDecision(invocation, rest[1:]) case "checkpoint": return parseCheckpoint(invocation, rest[1:]) - case "submission": - parsed, err := parseSubmission(invocation, rest[1:]) - topic := []string{"submission"} - if len(rest) > 1 { - topic = append(topic, rest[1]) - } - return parsed, wrapCommandError(err, topic...) default: return Invocation{}, &usageError{ message: fmt.Sprintf("unknown command %q", rest[0]), @@ -940,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 } diff --git a/cmd/mpc-ceremony/submission_command.go b/cmd/mpc-ceremony/submission_command.go deleted file mode 100644 index 62840d82..00000000 --- a/cmd/mpc-ceremony/submission_command.go +++ /dev/null @@ -1,447 +0,0 @@ -// Copyright 2026 Midgard Labs -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "crypto/ed25519" - "crypto/sha256" - "encoding/hex" - "errors" - "fmt" - "io" - "os" - "path/filepath" - "slices" - "strings" - - "golang.org/x/crypto/blake2b" - "proof-tool/internal/keybundle" - "proof-tool/internal/mpcceremony" -) - -func parseSubmission(invocation Invocation, args []string) (Invocation, error) { - if len(args) == 0 { - return Invocation{}, &usageError{message: "missing submission command", topic: []string{"submission"}} - } - if args[0] == "help" { - return Invocation{}, &helpRequest{topic: append([]string{"submission"}, args[1:]...)} - } - if args[0] == "accept" { - var options SubmissionAcceptOptions - fs := commandFlagSet("submission accept") - addCheckpointEvidenceFlags(fs, &options.CheckpointEvidenceOptions) - fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") - fs.StringVar(&options.OutDir, "out-dir", "", "fresh atomic acceptance output directory") - if err := parseFlags(fs, args[1:]); err != nil { - return invocation, err - } - if options.AcknowledgementPath != "" || options.AcknowledgementSignaturePath != "" { - return invocation, errors.New("submission accept creates the acknowledgement; do not supply acknowledgement flags") - } - validated := options.CheckpointEvidenceOptions - validated.AcknowledgementPath, validated.AcknowledgementSignaturePath = "/pending/acknowledgement.json", "/pending/acknowledgement.sig" - if err := validateCheckpointEvidenceOptions(validated); err != nil { - return invocation, err - } - kind := mpcceremony.CheckpointTransitionKind(options.TransitionKind) - if kind != mpcceremony.CheckpointPhase1ReceiptAccepted && kind != mpcceremony.CheckpointPhase1CandidateAccepted && kind != mpcceremony.CheckpointPhase2ReceiptAccepted && kind != mpcceremony.CheckpointPhase2CandidateAccepted { - return invocation, errors.New("submission accept supports only receipt-accepted and candidate-accepted transitions") - } - if err := requireValues(pathValue("--coordinator-signing-key", options.CoordinatorSigningKey), pathValue("--out-dir", options.OutDir)); err != nil { - return invocation, err - } - invocation.Command, invocation.Options = CommandSubmissionAccept, options - return invocation, nil - } - if args[0] != "sign" { - return Invocation{}, &usageError{message: fmt.Sprintf("unknown submission command %q", args[0]), topic: []string{"submission"}} - } - var options SubmissionSignOptions - fs := commandFlagSet("submission sign") - addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) - fs.StringVar(&options.ArtifactRoot, "artifact-root", "", "root containing the complete fetched checkpoint ancestry") - fs.StringVar(&options.CheckpointPath, "checkpoint", "", "exact allocation checkpoint") - fs.StringVar(&options.CheckpointSignaturePath, "checkpoint-signature", "", "allocation checkpoint signature") - fs.StringVar(&options.AttemptID, "attempt-id", "", "globally unique preallocated attempt ID") - fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "assigned participant private key") - fs.StringVar(&options.ReceiptPath, "receipt", "", "exact signed receipt record for a receipt slot") - fs.StringVar(&options.ReceiptSignaturePath, "receipt-signature", "", "detached receipt signature") - fs.StringVar(&options.CandidateDir, "candidate-dir", "", "completed candidate directory for a candidate slot") - fs.StringVar(&options.OutDir, "out-dir", "", "fresh atomic envelope output directory") - if err := parseFlags(fs, args[1:]); err != nil { - return invocation, err - } - if err := requireValues( - pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), - pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), pathValue("--artifact-root", options.ArtifactRoot), - pathValue("--checkpoint", options.CheckpointPath), pathValue("--checkpoint-signature", options.CheckpointSignaturePath), - value("--attempt-id", options.AttemptID), pathValue("--participant-signing-key", options.ParticipantSigningKey), pathValue("--out-dir", options.OutDir), - ); err != nil { - return invocation, err - } - receipt := options.ReceiptPath != "" || options.ReceiptSignaturePath != "" - candidate := options.CandidateDir != "" - if receipt == candidate { - return invocation, errors.New("supply exactly one payload form: --receipt with --receipt-signature, or --candidate-dir") - } - if receipt { - if err := requireValues(pathValue("--receipt", options.ReceiptPath), pathValue("--receipt-signature", options.ReceiptSignaturePath)); err != nil { - return invocation, err - } - } - invocation.Command, invocation.Options = CommandSubmissionSign, options - return invocation, nil -} - -func executeSubmissionSign(options SubmissionSignOptions) (CommandResult, error) { - checkpoint, checkpointBytes, err := verifyStoredCheckpointAncestry(CheckpointVerifyStoredOptions{ - InspectCheckpointOptions: InspectCheckpointOptions{InspectDefinitionOptions: InspectDefinitionOptions{CeremonyPath: options.CeremonyPath, CeremonySignaturePath: options.CeremonySignaturePath, CoordinatorPublicKeyFile: options.CoordinatorPublicKeyFile}, CheckpointPath: options.CheckpointPath, CheckpointSignaturePath: options.CheckpointSignaturePath}, - ArtifactRoot: options.ArtifactRoot, - }, options.CheckpointPath, options.CheckpointSignaturePath, make(map[string]struct{}), 0) - if err != nil { - return CommandResult{}, fmt.Errorf("allocation checkpoint ancestry: %w", err) - } - slot, err := allocatedSlotByAttempt(checkpoint, options.AttemptID) - if err != nil { - return CommandResult{}, err - } - trusted, definitionBytes, definitionSignatureBytes, err := loadExactInspectionCeremony(InspectDefinitionOptions{CeremonyPath: options.CeremonyPath, CeremonySignaturePath: options.CeremonySignaturePath, CoordinatorPublicKeyFile: options.CoordinatorPublicKeyFile}) - if err != nil { - return CommandResult{}, err - } - payloads, err := submissionPayloadRefs(options, slot) - if err != nil { - return CommandResult{}, err - } - envelope := mpcceremony.SubmissionEnvelopeV1{ - Schema: mpcceremony.SubmissionEnvelopeSchemaV1, Workflow: checkpoint.Workflow, - CeremonyID: checkpoint.CeremonyID, - Definition: mpcceremony.SignedArtifactRefs{ - Record: mpcceremony.ArtifactRef{Name: checkpoint.Definition.Record.Name, Digest: mpcceremony.NewDigest(definitionBytes)}, - Signature: mpcceremony.ArtifactRef{Name: checkpoint.Definition.Signature.Name, Digest: mpcceremony.NewDigest(definitionSignatureBytes)}, - }, - RelayReleaseID: checkpoint.RelayReleaseID, - SubmitterID: slot.IdentityID, SubmitterKeyID: participantKeyID(trusted.Definition, slot.IdentityID), SubmitterRole: mpcceremony.SubmissionRoleParticipant, - Kind: slot.Kind, Phase: slot.Phase, Index: slot.Index, ParentCheckpointSHA256: slot.BasisCheckpointSHA256, - AllocationCheckpointSHA256: mpcceremony.NewDigest(checkpointBytes).SHA256, ParentHeadID: slot.ParentHeadID, - AttemptID: slot.AttemptID, ManifestKey: slot.ManifestKey, Payloads: payloads, - } - // Kind-specific semantic verification happens before the private key is loaded. - if slot.Kind == mpcceremony.CheckpointSubmissionReceipt { - if err := verifyReceiptEnvelopePayloads(options.ArtifactRoot, trusted, checkpoint, envelope); err != nil { - return CommandResult{}, err - } - } else if err := verifyCandidateSubmissionFiles(options.CandidateDir, trusted.Definition, checkpoint, slot, payloads); err != nil { - return CommandResult{}, err - } - key, _, err := keybundle.LoadExistingPrivateKey(options.ParticipantSigningKey) - if err != nil { - return CommandResult{}, err - } - record, signature, err := mpcceremony.SignSubmissionEnvelope(trusted.Definition, checkpoint, slot, envelope, key) - if err != nil { - return CommandResult{}, err - } - if err := writeAtomicSubmissionDir(options.OutDir, map[string][]byte{"envelope.json": record, "envelope.sig": signature}); err != nil { - return CommandResult{}, err - } - return CommandResult{CeremonyID: checkpoint.CeremonyID, Phase: string(slot.Phase), Summary: fmt.Sprintf("signed authenticated %s submission for preallocated attempt", slot.Kind), Outputs: map[string]string{"envelope": filepath.Join(options.OutDir, "envelope.json"), "envelope_signature": filepath.Join(options.OutDir, "envelope.sig")}}, nil -} - -func executeSubmissionAccept(options SubmissionAcceptOptions) (CommandResult, error) { - var acknowledgementBytes, acknowledgementSignature []byte - var coordinatorKey ed25519.PrivateKey - options.AcceptanceSigner = func(trusted *mpcceremony.TrustedCeremony, checkpoint mpcceremony.Checkpoint, slot mpcceremony.CheckpointSubmissionSlot, envelope mpcceremony.SubmissionEnvelopeV1, envelopeRefs mpcceremony.SignedArtifactRefs, manifest mpcceremony.ArtifactRef) ([]byte, []byte, mpcceremony.SignedArtifactRefs, error) { - ack := mpcceremony.SubmissionAcknowledgementV1{ - Schema: mpcceremony.SubmissionAcknowledgementSchemaV1, Workflow: envelope.Workflow, - CeremonyID: envelope.CeremonyID, Definition: envelope.Definition, RelayReleaseID: envelope.RelayReleaseID, - CoordinatorID: trusted.Definition.Coordinator.ID, CoordinatorKeyID: trusted.Definition.Coordinator.KeyID, - SubmitterID: envelope.SubmitterID, SubmitterKeyID: envelope.SubmitterKeyID, SubmitterRole: envelope.SubmitterRole, - Kind: envelope.Kind, Phase: envelope.Phase, Index: envelope.Index, - ParentCheckpointSHA256: envelope.ParentCheckpointSHA256, AllocationCheckpointSHA256: envelope.AllocationCheckpointSHA256, - ParentHeadID: envelope.ParentHeadID, AttemptID: envelope.AttemptID, ManifestKey: envelope.ManifestKey, - Envelope: envelopeRefs, Manifest: manifest, Result: mpcceremony.SubmissionAccepted, - } - key, _, err := keybundle.LoadExistingPrivateKey(options.CoordinatorSigningKey) - if err != nil { - return nil, nil, mpcceremony.SignedArtifactRefs{}, err - } - record, signature, err := mpcceremony.SignSubmissionAcknowledgement(trusted.Definition, checkpoint, slot, envelope, envelopeRefs, manifest, ack, key) - if err != nil { - return nil, nil, mpcceremony.SignedArtifactRefs{}, err - } - base := "acknowledgements/" + slot.AttemptID - refs := mpcceremony.SignedArtifactRefs{ - Record: mpcceremony.ArtifactRef{Name: base + "/record.json", Digest: mpcceremony.NewDigest(record)}, - Signature: mpcceremony.ArtifactRef{Name: base + "/record.sig", Digest: mpcceremony.NewDigest(signature)}, - } - if err := refs.Validate(); err != nil { - return nil, nil, mpcceremony.SignedArtifactRefs{}, err - } - coordinatorKey, acknowledgementBytes, acknowledgementSignature = key, record, signature - return record, signature, refs, nil - } - built, err := buildCheckpointEvidence(options.CheckpointEvidenceOptions) - if err != nil { - return CommandResult{}, err - } - if len(coordinatorKey) == 0 || len(acknowledgementBytes) == 0 { - return CommandResult{}, errors.New("acceptance evidence did not reach authenticated signing boundary") - } - _, checkpointSignature, err := mpcceremony.SignRecord(built.checkpoint, built.trusted.Definition.Coordinator.KeyID, coordinatorKey) - if err != nil { - return CommandResult{}, err - } - files := map[string][]byte{"acknowledgement.json": acknowledgementBytes, "acknowledgement.sig": acknowledgementSignature, "checkpoint.json": built.canonical, "checkpoint.sig": checkpointSignature} - if err := writeAtomicSubmissionDir(options.OutDir, files); err != nil { - return CommandResult{}, err - } - return CommandResult{CeremonyID: built.checkpoint.CeremonyID, Sequence: int(built.checkpoint.Sequence), Summary: fmt.Sprintf("prepared signed acceptance checkpoint %d; not published", built.checkpoint.Sequence), Outputs: map[string]string{ - "acknowledgement": filepath.Join(options.OutDir, "acknowledgement.json"), "acknowledgement_signature": filepath.Join(options.OutDir, "acknowledgement.sig"), - "checkpoint": filepath.Join(options.OutDir, "checkpoint.json"), "checkpoint_signature": filepath.Join(options.OutDir, "checkpoint.sig"), - }}, nil -} - -func allocatedSlotByAttempt(checkpoint mpcceremony.Checkpoint, attemptID string) (mpcceremony.CheckpointSubmissionSlot, error) { - var found *mpcceremony.CheckpointSubmissionSlot - for i := range checkpoint.Submissions { - slot := checkpoint.Submissions[i] - if slot.AttemptID != attemptID { - continue - } - if found != nil { - return mpcceremony.CheckpointSubmissionSlot{}, errors.New("attempt ID is not globally unique in checkpoint") - } - copy := slot - found = © - } - if found == nil || found.Status != mpcceremony.CheckpointSubmissionAllocated { - return mpcceremony.CheckpointSubmissionSlot{}, errors.New("attempt ID does not name an allocated submission slot") - } - return *found, nil -} - -func participantKeyID(definition mpcceremony.CeremonyDefinition, id string) string { - participant, _ := definition.ParticipantByID(id) - return participant.Identity.KeyID -} - -func submissionPayloadRefs(options SubmissionSignOptions, slot mpcceremony.CheckpointSubmissionSlot) ([]mpcceremony.ArtifactRef, error) { - if slot.Kind == mpcceremony.CheckpointSubmissionReceipt { - if options.CandidateDir != "" { - return nil, errors.New("receipt slot requires receipt payloads") - } - base := fmt.Sprintf("%s/custody/%04d", slot.Phase, slot.Index) - refs := make([]mpcceremony.ArtifactRef, 0, 2) - for _, item := range []struct { - path, name string - limit int64 - }{{options.ReceiptPath, base + "/outbound-receipt.json", maxOperationalRecordBytes}, {options.ReceiptSignaturePath, base + "/outbound-receipt.sig", 4096}} { - ref, err := submissionFileRef(item.path, item.name, item.limit, 0) - if err != nil { - return nil, err - } - refs = append(refs, ref) - } - slices.SortFunc(refs, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) - return refs, nil - } - if slot.Kind != mpcceremony.CheckpointSubmissionCandidate || options.CandidateDir == "" || options.ReceiptPath != "" || options.ReceiptSignaturePath != "" { - return nil, errors.New("candidate slot requires only --candidate-dir") - } - base := fmt.Sprintf("%s/contributions/%04d", slot.Phase, slot.Index) - attestationBytes, err := readRegularOperationalFile(filepath.Join(options.CandidateDir, "attestation.json"), maxOperationalRecordBytes) - if err != nil { - return nil, err - } - var claimed mpcceremony.ContributionAttestation - if err := mpcceremony.UnmarshalCanonical(attestationBytes, &claimed); err != nil { - return nil, fmt.Errorf("candidate attestation: %w", err) - } - if claimed.OutputPayload.Digest.Size <= 0 || claimed.OutputPayload.Digest.Size > mpcceremony.MaxArtifactSize { - return nil, errors.New("candidate attestation has an invalid contribution size") - } - files := []struct { - file, name string - limit, exact int64 - }{ - {"attestation.json", base + "/attestation.json", maxOperationalRecordBytes, 0}, - {"attestation.sig", base + "/attestation.sig", 4096, 0}, - {"erasure.json", base + "/erasure.json", maxOperationalRecordBytes, 0}, - {"erasure.sig", base + "/erasure.sig", 4096, 0}, - {"contribution.bin", base + "/contribution.bin", claimed.OutputPayload.Digest.Size, claimed.OutputPayload.Digest.Size}, - } - refs := make([]mpcceremony.ArtifactRef, 0, len(files)) - for _, item := range files { - ref, err := submissionFileRef(filepath.Join(options.CandidateDir, item.file), item.name, item.limit, item.exact) - if err != nil { - return nil, err - } - refs = append(refs, ref) - } - slices.SortFunc(refs, func(a, b mpcceremony.ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) - return refs, nil -} - -func submissionFileRef(path, name string, maximum, exact int64) (mpcceremony.ArtifactRef, error) { - info, err := os.Lstat(path) - if err != nil { - return mpcceremony.ArtifactRef{}, err - } - if !info.Mode().IsRegular() { - return mpcceremony.ArtifactRef{}, errors.New("submission payload must be a regular file, not a symlink") - } - if info.Size() <= 0 || info.Size() > maximum || (exact > 0 && info.Size() != exact) { - return mpcceremony.ArtifactRef{}, errors.New("submission payload size is outside its authenticated bound") - } - f, err := os.Open(path) - if err != nil { - return mpcceremony.ArtifactRef{}, err - } - defer f.Close() - opened, err := f.Stat() - if err != nil || !os.SameFile(info, opened) { - return mpcceremony.ArtifactRef{}, errors.New("submission payload changed while being opened") - } - sha := sha256.New() - blake, _ := blake2b.New256(nil) - size, err := io.Copy(io.MultiWriter(sha, blake), f) - if err != nil { - return mpcceremony.ArtifactRef{}, err - } - if size != info.Size() { - return mpcceremony.ArtifactRef{}, errors.New("submission payload is empty or changed while hashing") - } - return mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size}}, nil -} - -func verifyCandidateSubmissionFiles(candidateDir string, definition mpcceremony.CeremonyDefinition, checkpoint mpcceremony.Checkpoint, slot mpcceremony.CheckpointSubmissionSlot, refs []mpcceremony.ArtifactRef) error { - participant, ok := definition.ParticipantByID(slot.IdentityID) - if !ok { - return errors.New("candidate submitter is not an assigned participant") - } - publicBytes, err := hex.DecodeString(participant.Identity.Ed25519PublicKeyHex) - if err != nil || len(publicBytes) != ed25519.PublicKeySize { - return errors.New("candidate participant has an invalid public key") - } - read := func(name string, limit int64) ([]byte, error) { - return readRegularOperationalFile(filepath.Join(candidateDir, name), limit) - } - attestationBytes, err := read("attestation.json", maxOperationalRecordBytes) - if err != nil { - return err - } - attestationSignature, err := read("attestation.sig", 4096) - if err != nil { - return err - } - var attestation mpcceremony.ContributionAttestation - if err := mpcceremony.VerifySignedRecord(attestationBytes, attestationSignature, &attestation, participant.Identity.KeyID, ed25519.PublicKey(publicBytes)); err != nil { - return fmt.Errorf("candidate attestation: %w", err) - } - erasureBytes, err := read("erasure.json", maxOperationalRecordBytes) - if err != nil { - return err - } - erasureSignature, err := read("erasure.sig", 4096) - if err != nil { - return err - } - var erasure mpcceremony.ErasureAttestation - if err := mpcceremony.VerifySignedRecord(erasureBytes, erasureSignature, &erasure, participant.Identity.KeyID, ed25519.PublicKey(publicBytes)); err != nil { - return fmt.Errorf("candidate cleanup record: %w", err) - } - if err := mpcceremony.ValidateErasureForContribution(attestation, erasure); err != nil { - return err - } - phaseState := checkpoint.Phase1 - if slot.Phase == mpcceremony.Phase2 { - if checkpoint.Phase2 == nil { - return errors.New("candidate slot has no authenticated Phase 2 state") - } - phaseState = *checkpoint.Phase2 - } - if attestation.CeremonyID != definition.CeremonyID || attestation.Phase != slot.Phase || attestation.Index != slot.Index || attestation.ParticipantID != slot.IdentityID || attestation.PreviousAcceptanceID != slot.ParentHeadID || attestation.PreviousPayload != phaseState.HeadPayload { - return errors.New("candidate attestation does not match the exact allocated slot") - } - var outputRef mpcceremony.ArtifactRef - for _, ref := range refs { - if strings.HasSuffix(ref.Name, "/contribution.bin") { - outputRef = ref - } - } - if outputRef != attestation.OutputPayload { - return errors.New("candidate contribution name or bytes do not match the signed attestation") - } - wantSmall := map[string]mpcceremony.Digest{ - baseNameForSubmissionRef(refs, "/attestation.json"): mpcceremony.NewDigest(attestationBytes), - baseNameForSubmissionRef(refs, "/attestation.sig"): mpcceremony.NewDigest(attestationSignature), - baseNameForSubmissionRef(refs, "/erasure.json"): mpcceremony.NewDigest(erasureBytes), - baseNameForSubmissionRef(refs, "/erasure.sig"): mpcceremony.NewDigest(erasureSignature), - } - for _, ref := range refs { - if want, ok := wantSmall[ref.Name]; ok && ref.Digest != want { - return errors.New("candidate signed record changed while being validated") - } - } - return nil -} - -func baseNameForSubmissionRef(refs []mpcceremony.ArtifactRef, suffix string) string { - for _, ref := range refs { - if strings.HasSuffix(ref.Name, suffix) { - return ref.Name - } - } - return "" -} - -func writeAtomicSubmissionDir(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("submission 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("submission output already exists with conflicting or incomplete contents") - } - } - entries, readErr := os.ReadDir(outDir) - if readErr != nil || len(entries) != len(files) { - return errors.New("submission output already exists with conflicting or incomplete contents") - } - return nil - } else if !os.IsNotExist(err) { - return err - } - tmp, err := os.MkdirTemp(parent, ".submission-*") - 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/submission_command_test.go b/cmd/mpc-ceremony/submission_command_test.go deleted file mode 100644 index 22aef26a..00000000 --- a/cmd/mpc-ceremony/submission_command_test.go +++ /dev/null @@ -1,245 +0,0 @@ -// Copyright 2026 Midgard Labs -// SPDX-License-Identifier: Apache-2.0 - -package main - -import ( - "bytes" - "crypto/ed25519" - "encoding/hex" - "os" - "path/filepath" - "runtime" - "strings" - "testing" - "time" - - "proof-tool/internal/mpcceremony" -) - -func TestSubmissionSignAuthenticatesReceiptSlotAndAncestry(t *testing.T) { - fixture := writeCheckpointCLIFixture(t) - cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) - cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) - var cp1 mpcceremony.Checkpoint - if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { - t.Fatal(err) - } - slot := cp1.Submissions[len(cp1.Submissions)-1] - participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) - receipt, err := mpcceremony.NewTransferReceipt(handoff, handoffBytes, mpcceremony.ReceiptReceiver, time.Now().UTC().Format(time.RFC3339Nano)) - if err != nil { - t.Fatal(err) - } - receiptDir := filepath.Join(fixture.root, "phase1", "custody", "0001") - if err := os.MkdirAll(receiptDir, 0o700); err != nil { - t.Fatal(err) - } - receiptPath, signaturePath := filepath.Join(receiptDir, "outbound-receipt.json"), filepath.Join(receiptDir, "outbound-receipt.sig") - receiptBytes, signatureBytes, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) - if err != nil { - t.Fatal(err) - } - writeDecisionTestFile(t, receiptPath, receiptBytes, 0o600) - writeDecisionTestFile(t, signaturePath, signatureBytes, 0o600) - keyPath := filepath.Join(fixture.root, "participant.hex") - writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(participantKey.Seed())+"\n"), 0o600) - out := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) - args := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), - "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, - "--attempt-id", slot.AttemptID, "--participant-signing-key", keyPath, - "--receipt", receiptPath, "--receipt-signature", signaturePath, "--out-dir", out) - result := runCheckpointCommandCLI(t, args) - envelopeBytes := mustReadTestFile(t, result.Outputs["envelope"]) - envelopeSignature := mustReadTestFile(t, result.Outputs["envelope_signature"]) - if _, err := mpcceremony.VerifySignedSubmissionEnvelope(fixture.definition, cp1, slot, envelopeBytes, envelopeSignature); err != nil { - t.Fatal(err) - } - if _, err := os.Stat(filepath.Join(out, "envelope.json")); err != nil { - t.Fatal(err) - } - runCheckpointCommandCLI(t, args) // byte-identical retry is idempotent - writeDecisionTestFile(t, filepath.Join(out, "envelope.sig"), []byte("conflict"), 0o600) - assertCheckpointCommandFails(t, args, "conflicting or incomplete") -} - -func TestSubmissionSignFailurePublishesNothing(t *testing.T) { - fixture := writeCheckpointCLIFixture(t) - cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) - cp1Path, cp1SignaturePath, _, _ := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) - var cp1 mpcceremony.Checkpoint - if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { - t.Fatal(err) - } - slot := cp1.Submissions[len(cp1.Submissions)-1] - key := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) - keyPath := filepath.Join(fixture.root, "participant.hex") - writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(key.Seed())+"\n"), 0o600) - out := filepath.Join(fixture.root, "must-not-exist") - args := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), - "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, - "--attempt-id", slot.AttemptID, "--participant-signing-key", keyPath, - "--receipt", filepath.Join(fixture.root, "missing.json"), "--receipt-signature", filepath.Join(fixture.root, "missing.sig"), "--out-dir", out) - assertCheckpointCommandFails(t, args, "no such file") - if _, err := os.Stat(out); !os.IsNotExist(err) { - t.Fatalf("failed signing published output: %v", err) - } -} - -func TestSubmissionAcceptCreatesAtomicReceiptAcknowledgementAndCheckpoint(t *testing.T) { - fixture := writeCheckpointCLIFixture(t) - cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) - cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) - var cp1 mpcceremony.Checkpoint - if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp1Path), &cp1); err != nil { - t.Fatal(err) - } - slot := cp1.Submissions[len(cp1.Submissions)-1] - participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x11}, ed25519.SeedSize)) - receipt, err := mpcceremony.NewTransferReceipt(handoff, handoffBytes, mpcceremony.ReceiptReceiver, time.Now().UTC().Format(time.RFC3339Nano)) - if err != nil { - t.Fatal(err) - } - receiptDir := filepath.Join(fixture.root, "phase1", "custody", "0001") - if err := os.MkdirAll(receiptDir, 0o700); err != nil { - t.Fatal(err) - } - receiptPath, receiptSignaturePath := filepath.Join(receiptDir, "outbound-receipt.json"), filepath.Join(receiptDir, "outbound-receipt.sig") - receiptBytes, receiptSignature, err := mpcceremony.SignRecord(receipt, fixture.definition.Roster[0].Identity.KeyID, participantKey) - if err != nil { - t.Fatal(err) - } - writeDecisionTestFile(t, receiptPath, receiptBytes, 0o600) - writeDecisionTestFile(t, receiptSignaturePath, receiptSignature, 0o600) - participantKeyPath := filepath.Join(fixture.root, "participant.hex") - writeDecisionTestFile(t, participantKeyPath, []byte(hex.EncodeToString(participantKey.Seed())+"\n"), 0o600) - envelopeDir := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) - signArgs := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", cp1Path, "--checkpoint-signature", cp1SignaturePath, "--attempt-id", slot.AttemptID, "--participant-signing-key", participantKeyPath, "--receipt", receiptPath, "--receipt-signature", receiptSignaturePath, "--out-dir", envelopeDir) - runCheckpointCommandCLI(t, signArgs) - manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) - if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil { - t.Fatal(err) - } - writeDecisionTestFile(t, manifestPath, []byte(`{"complete":true}`), 0o600) - coordinatorKeyPath := filepath.Join(fixture.root, "coordinator-accept.hex") - writeDecisionTestFile(t, coordinatorKeyPath, []byte(hex.EncodeToString(ed25519.PrivateKey(fixture.coordinatorKey).Seed())+"\n"), 0o600) - nextAttempt := strings.Repeat("b", 32) - out := filepath.Join(fixture.root, "acceptance") - args := append(append([]string{"--format", "json", "submission", "accept"}, fixture.trustArgs...), - "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", "--transition", string(mpcceremony.CheckpointPhase1ReceiptAccepted), - "--previous-checkpoint", cp1Path, "--previous-checkpoint-signature", cp1SignaturePath, - "--chain", fixture.chainPath, "--chain-signature", fixture.chainSignaturePath, "--head-payload", fixture.headPayloadPath, - "--transition-record", filepath.Join(envelopeDir, "envelope.json"), "--transition-record-signature", filepath.Join(envelopeDir, "envelope.sig"), - "--manifest", manifestPath, "--next-attempt-id", nextAttempt, "--next-manifest-key", "submissions/candidate/"+nextAttempt+"/manifest.json", - "--coordinator-signing-key", coordinatorKeyPath, "--out-dir", out) - result := runCheckpointCommandCLI(t, args) - if result.Sequence != 2 { - t.Fatalf("sequence = %d", result.Sequence) - } - checkpointBytes, signatureBytes := mustReadTestFile(t, result.Outputs["checkpoint"]), mustReadTestFile(t, result.Outputs["checkpoint_signature"]) - trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: fixture.trustArgs[1], DefinitionSignaturePath: fixture.trustArgs[3], CoordinatorPublicKeyPath: fixture.trustArgs[5]}) - if err != nil { - t.Fatal(err) - } - definitionBytes, definitionSignature := mustReadTestFile(t, fixture.trustArgs[1]), mustReadTestFile(t, fixture.trustArgs[3]) - checkpoint, err := mpcceremony.VerifySignedCheckpoint(trusted.Definition, definitionBytes, definitionSignature, checkpointBytes, signatureBytes) - if err != nil { - t.Fatal(err) - } - if checkpoint.Transition.Kind != mpcceremony.CheckpointPhase1ReceiptAccepted || checkpoint.Transition.Acknowledgement == nil { - t.Fatalf("checkpoint transition = %#v", checkpoint.Transition) - } - ackDir := filepath.Join(fixture.root, "acknowledgements", slot.AttemptID) - if err := os.MkdirAll(ackDir, 0o700); err != nil { - t.Fatal(err) - } - writeDecisionTestFile(t, filepath.Join(ackDir, "record.json"), mustReadTestFile(t, result.Outputs["acknowledgement"]), 0o600) - writeDecisionTestFile(t, filepath.Join(ackDir, "record.sig"), mustReadTestFile(t, result.Outputs["acknowledgement_signature"]), 0o600) - stored := runCheckpointCommandCLI(t, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", result.Outputs["checkpoint"], "--checkpoint-signature", result.Outputs["checkpoint_signature"])) - if stored.CheckpointEvidenceInspection == nil || !stored.CheckpointEvidenceInspection.FullyVerified { - t.Fatalf("stored acceptance = %#v", stored.CheckpointEvidenceInspection) - } - runCheckpointCommandCLI(t, args) // byte-identical retry -} - -func TestSubmissionCandidateSignAndAcceptReplaysMathematics(t *testing.T) { - if runtime.GOOS != "linux" { - t.Skip("full candidate replay requires Linux executable identity") - } - fixture, participantKey := writeWorkflowCheckpointCLIFixture(t) - cp0Path, cp0SignaturePath := prepareAndSignInitialCheckpoint(t, fixture) - cp1Path, cp1SignaturePath, handoff, handoffBytes := prepareAndSignOutboundCheckpoint(t, fixture, cp0Path, cp0SignaturePath, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) - cp2Path, cp2SignaturePath := prepareAndSignReceiptCheckpoint(t, fixture, participantKey, cp1Path, cp1SignaturePath, handoff, handoffBytes, mpcceremony.Phase1, fixture.chainPath, fixture.chainSignaturePath, fixture.headPayloadPath) - var cp2 mpcceremony.Checkpoint - if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, cp2Path), &cp2); err != nil { - t.Fatal(err) - } - var slot mpcceremony.CheckpointSubmissionSlot - for _, candidate := range cp2.Submissions { - if candidate.Kind == mpcceremony.CheckpointSubmissionCandidate && candidate.Status == mpcceremony.CheckpointSubmissionAllocated { - slot = candidate - } - } - if slot.AttemptID == "" { - t.Fatal("candidate slot missing") - } - chainPath := filepath.Join(fixture.root, "phase1", "chain-0001.json") - chainSignaturePath := filepath.Join(fixture.root, "phase1", "chain-0001.sig") - trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: fixture.trustArgs[1], DefinitionSignaturePath: fixture.trustArgs[3], CoordinatorPublicKeyPath: fixture.trustArgs[5]}) - if err != nil { - t.Fatal(err) - } - chain, _, err := mpcceremony.LoadSignedChainExact(trusted, mpcceremony.PhaseTranscriptPaths{RootDir: fixture.root, ChainPath: chainPath, ChainSignaturePath: chainSignaturePath}) - if err != nil { - t.Fatal(err) - } - accepted := chain.Records[len(chain.Records)-1] - candidateDir := filepath.Join(fixture.root, "candidate-for-submit") - if err := os.Mkdir(candidateDir, 0o700); err != nil { - t.Fatal(err) - } - for _, item := range []struct { - ref mpcceremony.ArtifactRef - name string - }{{accepted.OutputPayload, "contribution.bin"}, {accepted.Attestation, "attestation.json"}, {accepted.AttestationSignature, "attestation.sig"}, {accepted.Erasure, "erasure.json"}, {accepted.ErasureSignature, "erasure.sig"}} { - writeDecisionTestFile(t, filepath.Join(candidateDir, item.name), mustReadTestFile(t, filepath.Join(fixture.root, filepath.FromSlash(item.ref.Name))), 0o600) - } - participantKeyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "participant-01.ed25519.private.hex") - envelopeDir := filepath.Join(fixture.root, filepath.FromSlash(strings.TrimSuffix(slot.ManifestKey, "/manifest.json"))) - signArgs := append(append([]string{"--format", "json", "submission", "sign"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", cp2Path, "--checkpoint-signature", cp2SignaturePath, "--attempt-id", slot.AttemptID, "--participant-signing-key", participantKeyPath, "--candidate-dir", candidateDir, "--out-dir", envelopeDir) - runCheckpointFixtureCommand(t, fixture, signArgs) - manifestPath := filepath.Join(fixture.root, filepath.FromSlash(slot.ManifestKey)) - if err := os.MkdirAll(filepath.Dir(manifestPath), 0o700); err != nil { - t.Fatal(err) - } - writeDecisionTestFile(t, manifestPath, []byte(`{"complete":true}`), 0o600) - coordinatorKeyPath := filepath.Join(filepath.Dir(fixture.root), "identity-keys", "coordinator.ed25519.private.hex") - out := filepath.Join(fixture.root, "candidate-acceptance") - args := append(append([]string{"--format", "json", "submission", "accept"}, fixture.trustArgs...), - "--artifact-root", fixture.root, "--relay-release-id", "role-images-test", "--transition", string(mpcceremony.CheckpointPhase1CandidateAccepted), - "--previous-checkpoint", cp2Path, "--previous-checkpoint-signature", cp2SignaturePath, - "--chain", chainPath, "--chain-signature", chainSignaturePath, "--head-payload", filepath.Join(fixture.root, filepath.FromSlash(accepted.OutputPayload.Name)), - "--transition-record", filepath.Join(envelopeDir, "envelope.json"), "--transition-record-signature", filepath.Join(envelopeDir, "envelope.sig"), - "--manifest", manifestPath, "--coordinator-signing-key", coordinatorKeyPath, "--out-dir", out) - result := runCheckpointFixtureCommand(t, fixture, args) - if result.Sequence != 3 { - t.Fatalf("sequence = %d", result.Sequence) - } - var checkpoint mpcceremony.Checkpoint - if err := mpcceremony.UnmarshalCanonical(mustReadTestFile(t, result.Outputs["checkpoint"]), &checkpoint); err != nil { - t.Fatal(err) - } - if checkpoint.Transition.Kind != mpcceremony.CheckpointPhase1CandidateAccepted || checkpoint.Phase1.AcceptedCount != 1 { - t.Fatalf("checkpoint = %#v", checkpoint) - } - ackDir := filepath.Join(fixture.root, "acknowledgements", slot.AttemptID) - if err := os.MkdirAll(ackDir, 0o700); err != nil { - t.Fatal(err) - } - writeDecisionTestFile(t, filepath.Join(ackDir, "record.json"), mustReadTestFile(t, result.Outputs["acknowledgement"]), 0o600) - writeDecisionTestFile(t, filepath.Join(ackDir, "record.sig"), mustReadTestFile(t, result.Outputs["acknowledgement_signature"]), 0o600) - stored := runCheckpointFixtureCommand(t, fixture, append(append([]string{"--format", "json", "checkpoint", "verify-stored"}, fixture.trustArgs...), "--artifact-root", fixture.root, "--checkpoint", result.Outputs["checkpoint"], "--checkpoint-signature", result.Outputs["checkpoint_signature"])) - if stored.CheckpointEvidenceInspection == nil || !stored.CheckpointEvidenceInspection.FullyVerified { - t.Fatalf("stored candidate acceptance = %#v", stored.CheckpointEvidenceInspection) - } -} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 8378cac4..9ef23d95 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -64,41 +64,20 @@ const ( CommandInspectCheckpointTransition Command = "inspect checkpoint-transition" CommandInspectSubmission Command = "inspect submission" CommandInspectSubmissionAcknowledgement Command = "inspect submission-acknowledgement" - CommandSubmissionSign Command = "submission sign" - CommandSubmissionAccept Command = "submission accept" CommandCheckpointPrepare Command = "checkpoint prepare" CommandCheckpointSign Command = "checkpoint sign" CommandCheckpointVerify Command = "checkpoint verify" CommandCheckpointVerifyStored Command = "checkpoint verify-stored" CommandCheckpointPrepareV4 Command = "checkpoint prepare-v4" CommandCheckpointSignV4 Command = "checkpoint sign-v4" + CommandCheckpointAllocateV4 Command = "checkpoint allocate-v4" + CommandCheckpointAcceptCandidateV4 Command = "checkpoint accept-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 SubmissionSignOptions struct { - CeremonyPath string - CeremonySignaturePath string - CoordinatorPublicKeyFile string - ArtifactRoot string - CheckpointPath string - CheckpointSignaturePath string - AttemptID string - ParticipantSigningKey string - ReceiptPath string - ReceiptSignaturePath string - CandidateDir string - OutDir string -} - -type SubmissionAcceptOptions struct { - CheckpointEvidenceOptions - CoordinatorSigningKey string - OutDir string -} - type GlobalOptions struct { Format string Quiet bool @@ -153,6 +132,10 @@ type ContributeOptions struct { EnvironmentPath string ContributedAt string OutDir string + ArtifactRoot string + CheckpointPath string + CheckpointSignaturePath string + AttemptID string } type VerifyContributionOptions struct { diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 1c9ba385..f5b005d3 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -297,6 +297,31 @@ 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. +`, + "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. `, "checkpoint inspect-enrollments-v4": `Usage: mpc-ceremony checkpoint inspect-enrollments-v4 --ceremony FILE --ceremony-signature FILE \ @@ -340,7 +365,8 @@ 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 [flags] mpc-ceremony checkpoint inspect-signed-v4 [flags] mpc-ceremony checkpoint inspect-enrollments-v4 [flags] @@ -350,39 +376,10 @@ 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 canonical protocol proposals rather than legacy -submission envelopes. prepare-v4 and sign-v4 verify required transition 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. -`, - "submission": `Usage: - mpc-ceremony submission [flags] - -Participant-authored storage-first submission envelopes. The exact slot is -selected only by its coordinator-preallocated attempt ID. -`, - "submission accept": `Usage: - mpc-ceremony submission accept [checkpoint evidence flags except acknowledgement] \ - --coordinator-signing-key KEY --out-dir FRESH_DIR - -Replays the complete stored ancestry and the receipt or candidate evidence, -then creates the accepted acknowledgement and its descendant checkpoint as one -atomic four-file result. The coordinator key is loaded only after all untrusted -evidence passes verification. The acknowledgement is not acceptance by itself; -Relay must publish it only with the signed descendant checkpoint. -`, - "submission sign": `Usage: - mpc-ceremony submission sign --ceremony FILE --ceremony-signature FILE \ - --coordinator-public-key-file KEY --artifact-root DIR \ - --checkpoint FILE --checkpoint-signature FILE --attempt-id ID \ - --participant-signing-key KEY \ - (--receipt FILE --receipt-signature FILE | --candidate-dir DIR) \ - --out-dir FRESH_DIR - -Authenticates the complete stored checkpoint ancestry, derives the exact -allocated slot, hashes only its fixed receipt or candidate payload inventory, -and atomically writes the participant-signed envelope pair. It does not create -or upload the transport manifest and never accepts a submission. `, "checkpoint prepare": `Usage: mpc-ceremony checkpoint prepare --ceremony FILE --ceremony-signature FILE \ @@ -483,10 +480,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 \ @@ -565,7 +566,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 \ 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 index 3fe89d49..35574058 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -1,198 +1,99 @@ -# Ceremony compatibility baseline +# Ceremony schema compatibility + +## Released formats are frozen Verified against released commit `47bec5663d04a4f8ac330fc38f126e6e7c1140f1` -(PR #31, September 15, 2026). These meanings are frozen. The trusted-coordinator -revision is not implemented by this inventory. +(PR #31, September 15, 2026). Existing ceremonies keep their signed definition, +software allowlist and verification rules. -| Boundary | Released versions | Preserve | +| Boundary | Released versions | Preserved meaning | | --- | --- | --- | -| Ceremony definition | `proof-tool-mpc-ceremony-definition-v1/v2/v3` | V1 single binary; V2 allowlist; V1/V2 implicit assurance minima; V3 explicit optional assurance policy | -| Checkpoint | `proof-tool-mpc-checkpoint-v1/v2/v3` | V1 legacy definition; V2 optional assurance and early Phase 1; V3 both phases through final release | -| Checkpoint workflow | `storage-first-v1` | Allocated attempts and manifest keys; signed participant envelopes and coordinator acknowledgements | -| Submission | `proof-tool-mpc-submission-envelope-v1`, `proof-tool-mpc-submission-acknowledgement-v1` | Exact identity, phase, turn, parent, attempt, manifest and payload binding | -| Operational bundle | `proof-tool-mpc-operational-evidence-bundle-v2/v3` | V2 legacy minima; V3 exact assurance projection and explicit empty disabled collections | -| Component records | Enrollment/handoff/receipt/witness/beacon-evidence/mirror/governance V1; contribution/erasure V2 | Existing signed claims, identities and exact file bindings | -| Final candidate | `proof-tool-mpc-release-candidate-v2` | Coordinator replay and exact final file inventory | -| Final transcript | `proof-tool-mpc-final-transcript-v1/v2` | V1 requires audit; V2 explicit assurance policy and audit list | -| Release manifest | `proof-tool-key-manifest-v1` | Existing application bundle format; Definition V3 ceremony signing additionally requires signer replay | -| Signed release ID | `proof-tool/mpc-ceremony/signed-release/v1` | Exact existing signed-release binding | -| Production decision and draft | `proof-tool-mpc-production-decision-v1/v2`, corresponding `-draft-v1/v2` | V1 audit requirements; V2 exact optional-assurance gates | -| Decision signature | `proof-tool-mpc-production-decision-signature-v1` | Existing signed decision identity and bytes | - -Coordinator `PrepareFinalization` and `Finalize` already required `replayAll` -in `c1f177ee486fd555fac0dc4d9812b86737fccdfd` (July 31). PR #31 added the -additional signer replay requirement for Definition V3. Do not remove that -check by changing the meaning of V3 or a generic "current schema" constant. - -## New-version implementation gate - -New read-only discovery APIs preserve the old inspection wire shape: -`inspect definition-protocol` authenticates V1–V4 before reporting its exact -format and derived workflow. `checkpoint inspect-signed-v4` authenticates one -V4 pair and returns its predecessor plus bounded stored-verifier dependencies; -it does not authorize progress. The caller must stage and verify the complete -ancestry with `checkpoint verify-stored-v4` before using it. No cumulative -contribution payload inventory is downloaded for this structural check. -Fresh-copy tests cover incident, abort, restart and accepted-contribution -history; removing each required dependency fails verification. The discovery -signature alone intentionally does not prove that an edge is legal. - -The V4 stored projection also includes an ancestry-derived commitment index: -exact enrollment pairs and per-turn outbound history, accepted receipt, accepted -chain/result, and return records. These are locations, not verified payloads. -`checkpoint inspect-enrollments-v4` batch-verifies the exact committed enrollment -set against that head, including proof of possession and the committed disclosure -reference. Its output includes the structural index from the same ancestry walk, -so consumers need not verify that history twice. Discovery labels enrollment -pairs separately from structural dependencies. It does not read disclosure -contents or claim roster completeness. -Outbounds remain newest-first, but a signed receipt may acknowledge an older -valid packet; publication attempts do not change that signed acknowledgement. - -Current draft: opt-in V4 construction, structural checkpoints and real-artifact -verification through final-candidate recording exist in the library. The Linux -integration test uses real tiny contributions in both phases, signed custody -records, genuine historical drand responses and complete coordinator replay. -Final-candidate authoring binds the exact executable and closed file inventory. Its -environment and cleanup claims are test fixtures, not physical assurance. -Audit collection verifies each signed record and keeps the full release quorum. -Operational bundle preparation derives the unchanged v3 bundle only from exact -checkpointed records, including all required enrollments and per-turn custody. -It runs the existing bundle verifier without signing or repeating mathematics. -Its source-checkpoint metadata must be rebound at final release; it is not an -extra field in the signed legacy bundle. Linux tests reject missing enrollments, -loose uncommitted records and corrupted retained evidence. -V4 governance uses the existing signed records with stricter explicit -coordinator/current-head checks. Informational incidents enter the bundle; -abort/restart are terminal and cannot prepare a release bundle. An authorized -restart points to an exact signed V4 definition; the new definition alone does -not establish lineage. Historical inspection rechecks these governance edges -against their exact predecessor, not just the record signature. -The read-only V4 final-review API now binds an exact review checkpoint, -coordinator replay checkpoint, closed candidate inventory, signed/rederived -bundle and checkpoint-derived audit quorum. It authenticates lifecycle records, -verifies key exports and the public proof, but does not replay contributions or -regenerate keys. Existing V1–V3 replay/verification gates are unchanged. -V4 operational verification checks historical payload references through signed -records rather than requiring the large genesis/contribution bytes themselves. -It still checks all required custody, cleanup, enrollment, observer and beacon -evidence. The final-review gate separately requires the coordinator replay claim; -checking an operational bundle alone does not establish that replay. V1–V3 -continue requiring and hashing every historical payload. The review's sorted -dependency list is tested by copying only those files and re-verifying from the -copied definition and signature, without historical contribution binaries. -The V4 library now signs and verifies a local package with an inline exact review -in FinalTranscript V3, while preserving the application manifest V1 and root-level -key files. Only the fixed candidate filename set is relocated; all other logical -names remain unchanged. Independent copies, exact file inventories, copied-byte -review, and destination verification also cover exact retries. The output must -be outside the source tree. Transcript V3 alone has a dedicated 64 MiB bound; -ordinary signed JSON remains limited to 16 MiB. Package time is not proof of -upload, and package signing is not a production GO decision. -Final-release checkpoint authoring verifies that complete package and binds its -exact review predecessor. The checkpoint adds only five canonical bootstrap -references under `final/release/`; a typed inventory returns all package-relative -files separately from that prefix. `VerifyStoredCheckpointV4` remains structural -inspection; `VerifyFinalReleaseCheckpointV4` additionally verifies package bytes. -Recording this edge means a signed private package, not public publication or GO. -Only this edge has five reserved artifact slots and the exact transcript and -checksum size exceptions. Released checkpoint formats keep their limits. -The Decision V3 library now binds that compact release checkpoint, derives the -exact auditor signer set from its package, and preserves all production gates. -It requires a production-mode Definition V4 and the exact K21 circuit; tiny or -rehearsal-mode definitions cannot receive production approval. Source evidence -is a bounded report under `decision/evidence/`, not an obsolete GPG tag or a -private URL. Proof-tool binds the source commit/report and decision signatures; -the delivery tool must verify and display CI provenance before approval. -Package-derived gates are checked facts; external gates remain reviewed claims. -An independently reloaded package must match the initially authenticated -definition exactly. Legacy decision structs, gates and hash domains are unchanged. -Current tests cover the new record/binding/signature/evidence rules and reject a -missing package. A full public-API positive with a real K21 package is pending; -these tests do not establish an actual production GO or operational assurances. -Decision prepare/sign/verify now dispatch from the authenticated definition. -V4 requires a local evidence root even for post-package NO-GO, checks evidence -before loading the signing key, and keeps decision outputs outside the immutable -release package. Early stops use the existing authenticated abort procedure. -Old decision behavior remains unchanged; only V4 accepts evidence-root during -preparation. CLI tests cover signed format dispatch, missing roots, evidence -failure before key loading, and output containment including symlink aliases. -Release sign now accepts an exact review checkpoint pair for V4 instead of -legacy candidate/audit/replay flags; mixing the two input forms is rejected. -Metadata references are root-confined and size-bounded. The signing library -rechecks the running executable against its authenticated definition before -loading the release key. Release verify selects the V4 package verifier from -the signed definition. These commands create/check local packages, not public -publication or production authorization; legacy V3 signer replay is unchanged. -Normal initialization still emits V3. Do not release this slice alone: remaining -production integration and normal storage-first CLI guidance are incomplete. -An explicit `init --release-verification coordinator-full-replay-v1` now opts a -fresh ceremony into V4; it never upgrades an existing definition. The separate -`checkpoint prepare-v4`, `sign-v4`, and `verify-stored-v4` commands leave legacy -parsers unchanged. Signing repeats preparation and preserves exact canonical -bytes before key loading. Only the six mathematical transition types load the -authenticated stored R1CS. Structural inspection emits a versioned projection -with explicit false artifact/replay/freshness claims. Prepared and signed local -proposals are not published current heads. The tiny executable regression creates -V3 and V4 ceremonies, then prepares/signs/inspects initial V4 state and rejects -changed genesis before signing; this is not yet a whole storage-backed role journey. -Proposal/output paths cannot enter closed candidate/release/rejection trees, -including symlink aliases. Private rejected candidates must be disjoint from -the public artifact root. Existing output files are retained for inspection. -These tests are not a complete user ceremony. - -The explicit V4 evidence commands bind bundle preparation and signing to an -exact checkpoint pair; signing rederives the reviewed canonical bytes before -loading the coordinator key. Outputs use the canonical operational bundle paths, -and legacy bundle preparation/signing rejects V4. `release review-v4` writes a -bounded unsigned local report; signing recomputes the review and does not accept -that report as authority. `checkpoint verify-release-v4` verifies the complete -private package and exports a bounded, package-relative inventory report. That -report is not a trusted download list or a production authorization. The delivery -tool must use authenticated bootstrap files and rerun verification after download. -Both reports stay outside the closed candidate and release trees. Ordinary record -and signature bounds remain 16 MiB and 4 KiB; only these large local reports use -64 MiB. The Linux ARM64 integration invokes the actual approved CLI against real -tiny artifacts, using a helper and command binary with distinct approved ARM64 -feature variants. A modified command executable fails before output or key access. -The test uses known local keys and historical beacon responses, not independent -operators or a live storage journey. Legacy bundle export/import also rejects V4; -generic bundle verification explicitly does not establish exact-head equivalence. -Inventory reports have strict canonical shape/claim validation, which does not -make them trusted authority. A full released CLI journey is still required. - -- Reserve Definition V4, Checkpoint V4 and `storage-first-v2` for the changed - trust and submission rules; do not emit them until the whole verifier path - exists and has negative tests. -- Give the changed ceremony release claim final transcript V3, with explicit - policy and the exact signed coordinator final-candidate checkpoint references. - Keep key manifest V1: its signed setup_transcript_hash binds the new transcript - without changing ordinary application key-bundle verification or adding a - second authorization signature. Include the checkpoint pair in the closed - ceremony release inventory. -- Explicitly dispatch Definition V4 to final transcript V3 and Decision V3. - Decision V2's enumerated tree and bounds cannot represent every V4 package; - do not widen its released limits or fall through to legacy policy. Reuse component - evidence/candidate formats only where their - exact signed meaning stays unchanged. -- Keep old signing and verification dispatch intact. Unknown versions fail - closed; missing fields do not select the simplified path. -- Coordinator replay stays mandatory. Release signer stays required; only its - duplicate mathematical replay becomes optional in the new path. - -V4 delivery history retains terminal dispositions. Its per-turn -`contribution_result_id` hashes the ceremony, phase, index, participant, -predecessor and fixed candidate-file labels/digests, not upload attempts or -paths. Retired delivery permits the same bytes to be redelivered; rejected -results cannot be accepted through replacement attempts. The signed history is -bounded to 16 attempts per logical submission and a separate 4,096-slot budget -across the ceremony. Validators reject excess history rather than dropping old -rejections. Retirement/rejection need not allocate a replacement, so exhausting -the budget does not prevent terminal retirement. Closure still requires the -signed contribution minimum. `VerifyCheckpointEdgeV4` compares the exact -predecessor record and signature; structural validation alone cannot do that. - -The runtime has no dependency on a downstream delivery application's version. -Provider keys, buckets and upload manifests belong outside proof-tool. Existing -released coupling remains legacy behavior; new protocol outputs use logical -artifact names and hashes only. +| 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. + +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 58d8f995..8cf2b715 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -118,15 +118,16 @@ 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 a five-file inventory -after computation and cleanup attestation, and a distinct seven-file inventory -after the signed return packet is complete. Partial or inconsistent return -packets are errors; they must not trigger another computation. Only the final -seven-file identity is used for candidate delivery/acceptance comparisons. +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. Uploaders must recheck the returned file hashes. Extra local files +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. diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index d66054cd..15d86939 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -8,20 +8,22 @@ import ( ) const ( - CheckpointSchemaV4 = "proof-tool-mpc-checkpoint-v4" - StorageFirstWorkflowV2 = "storage-first-v2" - MaxCheckpointSequenceV4 = 16384 - CheckpointDeliveryRetired CheckpointTransitionKind = "delivery-retired" - CheckpointContributionRejected CheckpointTransitionKind = "contribution-rejected" - CheckpointDeliveryReallocated CheckpointTransitionKind = "delivery-reallocated" - CheckpointEnrollmentRecorded CheckpointTransitionKind = "enrollment-recorded" - CheckpointMirrorRecorded CheckpointTransitionKind = "mirror-recorded" - CheckpointWitnessRecorded CheckpointTransitionKind = "witness-recorded" - CheckpointBeaconEvidenceRecorded CheckpointTransitionKind = "beacon-evidence-recorded" - CheckpointAuditRecorded CheckpointTransitionKind = "audit-recorded" - CheckpointIncidentRecorded CheckpointTransitionKind = "incident-recorded" - CheckpointAborted CheckpointTransitionKind = "ceremony-aborted" - CheckpointRestarted CheckpointTransitionKind = "ceremony-restarted" + 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" + CheckpointBeaconEvidenceRecorded CheckpointTransitionKind = "beacon-evidence-recorded" + CheckpointAuditRecorded CheckpointTransitionKind = "audit-recorded" + CheckpointIncidentRecorded CheckpointTransitionKind = "incident-recorded" + CheckpointAborted CheckpointTransitionKind = "ceremony-aborted" + CheckpointRestarted CheckpointTransitionKind = "ceremony-restarted" ) // CheckpointProgressV4 is the protocol projection used for guidance. It is not @@ -52,6 +54,7 @@ type CheckpointTransitionV4 struct { 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"` @@ -149,6 +152,11 @@ func (c CheckpointV4) Validate() error { 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 } @@ -244,12 +252,12 @@ func (t CheckpointTransitionV4) Validate() error { return err } if t.Kind == CheckpointInitial { - if t.Scope != nil || t.AttemptID != "" || t.NextAttemptID != "" || t.Record != nil || t.Contribution != nil || len(t.Evidence) != 0 { + 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 == CheckpointPhase1OutboundPublished || t.Kind == CheckpointPhase2OutboundPublished || t.Kind == CheckpointPhase1ReceiptAccepted || t.Kind == CheckpointPhase2ReceiptAccepted || t.Kind == CheckpointPhase1CandidateAccepted || t.Kind == CheckpointPhase2CandidateAccepted || t.Kind == CheckpointDeliveryRetired || t.Kind == CheckpointContributionRejected || t.Kind == CheckpointDeliveryReallocated + 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") @@ -261,13 +269,13 @@ func (t CheckpointTransitionV4) Validate() error { return err } wantPhase := Phase1 - if t.Kind == CheckpointPhase2OutboundPublished || t.Kind == CheckpointPhase2ReceiptAccepted || t.Kind == CheckpointPhase2CandidateAccepted { + if t.Kind == CheckpointPhase2CandidateAllocated || t.Kind == CheckpointPhase2CandidateAccepted { wantPhase = Phase2 } if t.Kind != CheckpointDeliveryRetired && t.Kind != CheckpointContributionRejected && t.Kind != CheckpointDeliveryReallocated && t.Scope.Phase != wantPhase { return errors.New("transition kind and phase disagree") } - replacement := t.Kind == CheckpointPhase1ReceiptAccepted || t.Kind == CheckpointPhase2ReceiptAccepted || t.Kind == CheckpointDeliveryReallocated || ((t.Kind == CheckpointDeliveryRetired || t.Kind == CheckpointContributionRejected) && t.NextAttemptID != "") + replacement := t.Kind == CheckpointDeliveryReallocated || ((t.Kind == CheckpointDeliveryRetired || t.Kind == CheckpointContributionRejected) && t.NextAttemptID != "") if replacement { if err := validateHex(t.NextAttemptID, 16); err != nil { return err @@ -290,6 +298,20 @@ func (t CheckpointTransitionV4) Validate() error { 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") + } + if 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 + } if 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") @@ -297,7 +319,7 @@ func (t CheckpointTransitionV4) Validate() error { return nil } } else { - if t.Scope != nil || t.AttemptID != "" || t.NextAttemptID != "" || t.Contribution != nil { + 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 { @@ -594,30 +616,16 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { return errors.New("transition does not identify its exact active delivery") } switch t.Kind { - case CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished: - if len(t.Evidence) != 0 { - return errors.New("outbound edge adds only its signed handoff") - } + 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, CheckpointSubmissionReceipt, t.AttemptID) - case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: - if len(t.Evidence) != 0 { - return errors.New("receipt edge adds only its signed receipt") - } - if err = findActive(CheckpointSubmissionReceipt); err != nil { - return err - } - want, err = AdvanceDeliveryV2(previous.Deliveries, t.AttemptID, DeliveryAccepted, nil) - if err == nil { - want, err = AllocateDeliveryV2(want, scope, CheckpointSubmissionCandidate, t.NextAttemptID) - } + want, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionCandidate, t.AttemptID) case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: - if len(t.Contribution.Files) != 7 { - return errors.New("candidate acceptance requires the signed return handoff") + 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 @@ -634,8 +642,8 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { 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)+3 { - return errors.New("candidate acceptance requires complete candidate, verification and signed return receipt") + 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} @@ -649,24 +657,13 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { if !slices.ContainsFunc(t.Evidence, func(ref ArtifactRef) bool { return ref.Name == base+"verification.json" }) { return errors.New("candidate acceptance lacks coordinator verification record") } - for _, name := range []string{"return-receipt.json", "return-receipt.sig"} { - if !slices.ContainsFunc(t.Evidence, func(ref ArtifactRef) bool { return ref.Name == base+name }) { - return errors.New("candidate acceptance lacks signed return receipt") - } - } if scope.Phase == Phase1 { wantProgress.Phase1 = state } else { wantProgress.Phase2 = &state } case CheckpointDeliveryRetired, CheckpointContributionRejected: - kind := CheckpointSubmissionCandidate - for _, slot := range previous.Deliveries { - if slot.AttemptID == t.AttemptID { - kind = slot.Kind - } - } - if err = findActive(kind); err != nil { + if err = findActive(CheckpointSubmissionCandidate); err != nil { return err } status := DeliveryRetired @@ -675,7 +672,7 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { } want, err = AdvanceDeliveryV2(previous.Deliveries, t.AttemptID, status, t.Contribution) if err == nil && t.NextAttemptID != "" { - want, err = AllocateDeliveryV2(want, scope, kind, t.NextAttemptID) + want, err = AllocateDeliveryV2(want, scope, CheckpointSubmissionCandidate, t.NextAttemptID) } case CheckpointDeliveryReallocated: index := -1 @@ -696,7 +693,7 @@ func validateV4TurnTransition(previous, next CheckpointV4) error { return errors.New("replacement must follow the most recent delivery for this submission") } } - want, err = AllocateDeliveryV2(previous.Deliveries, scope, old.Kind, t.NextAttemptID) + want, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionCandidate, t.NextAttemptID) default: return errors.New("unsupported turn transition") } diff --git a/internal/mpcceremony/checkpoint_v4_bundle.go b/internal/mpcceremony/checkpoint_v4_bundle.go index ec66bb1d..c99b1321 100644 --- a/internal/mpcceremony/checkpoint_v4_bundle.go +++ b/internal/mpcceremony/checkpoint_v4_bundle.go @@ -3,7 +3,6 @@ package mpcceremony import ( "errors" "fmt" - "path" "slices" "strings" "time" @@ -136,7 +135,7 @@ func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCerem raws[r.Phase] = append(raws[r.Phase], ob.RawResponse) } } - bundle := OperationalEvidenceBundle{Schema: OperationalEvidenceBundleSchema, 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)} + 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 { @@ -175,32 +174,7 @@ func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCerem return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d lacks its accepted checkpoint", phase, record.Index) } used++ - receipt, ok := a.receipts[scope] - if !ok { - return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d lacks its committed input receipt", phase, record.Index) - } - var received TransferReceipt - if err = read(receipt, &received); err != nil { - return OperationalEvidenceBundle{}, err - } - handoff, ok := a.outbound[received.HandoffSHA256] - if !ok { - return OperationalEvidenceBundle{}, errors.New("accepted receipt has no committed original handoff") - } - files := map[string]ArtifactRef{} - for _, r := range tx.Evidence { - files[r.Name] = r - } - dir := path.Dir(record.Attestation.Name) - returnHandoff := SignedArtifactRefs{Record: files[dir+"/return-handoff.json"], Signature: files[dir+"/return-handoff.sig"]} - returnReceipt := SignedArtifactRefs{Record: files[dir+"/return-receipt.json"], Signature: files[dir+"/return-receipt.sig"]} - if err = returnHandoff.Validate(); err != nil { - return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d missing committed return handoff: %w", phase, record.Index, err) - } - if err = returnReceipt.Validate(); err != nil { - return OperationalEvidenceBundle{}, fmt.Errorf("%s turn %d missing committed return receipt: %w", phase, record.Index, err) - } - pe.AcceptedHeads = append(pe.AcceptedHeads, AcceptedHeadOperationalEvidence{Index: record.Index, PredecessorHeadID: record.PreviousRecordID, AcceptedHeadID: record.RecordID, OutboundHandoff: handoff, OutboundReceipt: receipt, ReturnHandoff: returnHandoff, ReturnReceipt: returnReceipt, AcceptedChainPrefix: *tx.Record, MirrorReceipts: sortedSignedRefsV4(mirrors[phase][record.Index])}) + 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 diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go index 3d4df97f..34d05155 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments.go +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -2,7 +2,6 @@ package mpcceremony import ( "errors" - "fmt" "slices" "strings" ) @@ -15,15 +14,10 @@ type CheckpointCommitmentsV4 struct { Turns []TurnCommitmentV4 `json:"turns"` } -type OutboundCommitmentV4 struct { - CheckpointSequence uint64 `json:"checkpoint_sequence"` - PublishedAttemptID string `json:"published_attempt_id"` - Pair SignedArtifactRefs `json:"pair"` -} - -type AcceptedTurnRecordV4 struct { - AttemptID string `json:"attempt_id"` - Pair SignedArtifactRefs `json:"pair"` +type CandidateAllocationV4 struct { + CheckpointSequence uint64 `json:"checkpoint_sequence"` + AttemptID string `json:"attempt_id"` + AllocatedAt string `json:"allocated_at"` } type AcceptedChainCommitmentV4 struct { @@ -33,20 +27,15 @@ type AcceptedChainCommitmentV4 struct { } type TurnCommitmentV4 struct { - Scope ContributionScope `json:"scope"` - // Newest first. PublishedAttemptID records transport history only; a later - // accepted receipt may acknowledge an older still-valid packet in this list. - Outbounds []OutboundCommitmentV4 `json:"outbounds"` - InputReceipt *AcceptedTurnRecordV4 `json:"input_receipt,omitempty"` + Scope ContributionScope `json:"scope"` + Allocations []CandidateAllocationV4 `json:"allocations"` AcceptedChain *AcceptedChainCommitmentV4 `json:"accepted_chain,omitempty"` - ReturnHandoff *SignedArtifactRefs `json:"return_handoff,omitempty"` - ReturnReceipt *SignedArtifactRefs `json:"return_receipt,omitempty"` } func collectTurnCommitmentV4(turns map[ContributionScope]*TurnCommitmentV4, c CheckpointV4) error { t := c.Transition switch t.Kind { - case CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished, CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted, CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: + case CheckpointPhase1CandidateAllocated, CheckpointPhase2CandidateAllocated, CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: default: return nil } @@ -56,20 +45,15 @@ func collectTurnCommitmentV4(turns map[ContributionScope]*TurnCommitmentV4, c Ch if len(turns) >= 2*MaxParticipants { return errors.New("turn commitment index exceeds protocol capacity") } - turn = &TurnCommitmentV4{Scope: scope, Outbounds: []OutboundCommitmentV4{}} + turn = &TurnCommitmentV4{Scope: scope, Allocations: []CandidateAllocationV4{}} turns[scope] = turn } switch t.Kind { - case CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished: - if len(turn.Outbounds) >= MaxDeliveryAttemptsPerSubmissionV2 { - return errors.New("outbound commitment index exceeds receipt-attempt limit") - } - turn.Outbounds = append(turn.Outbounds, OutboundCommitmentV4{CheckpointSequence: c.Sequence, PublishedAttemptID: t.AttemptID, Pair: *t.Record}) - case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: - if turn.InputReceipt != nil { - return errors.New("duplicate accepted receipt commitment") + case CheckpointPhase1CandidateAllocated, CheckpointPhase2CandidateAllocated: + if len(turn.Allocations) >= MaxDeliveryAttemptsPerSubmissionV2 { + return errors.New("candidate allocation index exceeds attempt limit") } - turn.InputReceipt = &AcceptedTurnRecordV4{AttemptID: t.AttemptID, Pair: *t.Record} + turn.Allocations = append(turn.Allocations, CandidateAllocationV4{CheckpointSequence: c.Sequence, AttemptID: t.AttemptID, AllocatedAt: t.AllocatedAt}) case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: if turn.AcceptedChain != nil { return errors.New("duplicate candidate commitment") @@ -79,30 +63,6 @@ func collectTurnCommitmentV4(turns map[ContributionScope]*TurnCommitmentV4, c Ch return err } turn.AcceptedChain = &AcceptedChainCommitmentV4{AttemptID: t.AttemptID, ContributionResultID: resultID, Pair: *t.Record} - base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) - pair := func(name string) (*SignedArtifactRefs, error) { - p := SignedArtifactRefs{} - for _, ref := range t.Evidence { - if ref.Name == base+name+".json" { - p.Record = ref - } - if ref.Name == base+name+".sig" { - p.Signature = ref - } - } - if err := p.Validate(); err != nil { - return nil, err - } - return &p, nil - } - turn.ReturnHandoff, err = pair("return-handoff") - if err != nil { - return err - } - turn.ReturnReceipt, err = pair("return-receipt") - if err != nil { - return err - } } return nil } diff --git a/internal/mpcceremony/checkpoint_v4_commitments_test.go b/internal/mpcceremony/checkpoint_v4_commitments_test.go index 51a7d7c1..1f3cc9fa 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments_test.go +++ b/internal/mpcceremony/checkpoint_v4_commitments_test.go @@ -7,7 +7,6 @@ import ( "os" "path/filepath" "reflect" - "strings" "testing" ) @@ -45,77 +44,47 @@ func TestCheckpointCommitmentsSurviveUnrelatedEdges(t *testing.T) { if err != nil { t.Fatal(err) } - if c.Sequence != 4 || len(index.Enrollments) != 1 || index.Enrollments[0] != roster || len(index.Turns) != 1 { + 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.Outbounds) != 1 || turn.Outbounds[0].Pair != *sequence[1].Transition.Record || turn.InputReceipt == nil || turn.InputReceipt.Pair != *sequence[2].Transition.Record || turn.AcceptedChain == nil || turn.AcceptedChain.Pair != *sequence[3].Transition.Record || turn.ReturnReceipt == nil || turn.ReturnHandoff == nil { + 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) } - // The index describes commitments only; none of these payloads were needed. if _, err := os.Stat(filepath.Join(root, roster.Record.Name)); !os.IsNotExist(err) { t.Fatal("unexpected enrollment bytes") } } -func TestCheckpointCommitmentsRetainAllRetriedOutbounds(t *testing.T) { +func TestCheckpointCommitmentsRetainCandidateAllocations(t *testing.T) { d, initial, db, ds := checkpointFixtureV4(t) - template := checkpointTurnV4(t, d, initial, Phase1) - a := template[1] - scope := *a.Transition.Scope - retired := nextCheckpointV4(t, a, CheckpointTransitionV4{Kind: CheckpointDeliveryRetired, Scope: &scope, AttemptID: a.Transition.AttemptID, Evidence: []ArtifactRef{}}) + 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(a.Deliveries, a.Transition.AttemptID, DeliveryRetired, nil) + retired.Deliveries, err = AdvanceDeliveryV2(first.Deliveries, first.Transition.AttemptID, DeliveryRetired, nil) if err != nil { t.Fatal(err) } - bPair := checkpointSigned("phase1/outbound-new") - bAttempt := strings.Repeat("ab", 16) - b := nextCheckpointV4(t, retired, CheckpointTransitionV4{Kind: CheckpointPhase1OutboundPublished, Scope: &scope, AttemptID: bAttempt, Record: &bPair, Evidence: []ArtifactRef{}}) - b.Deliveries, err = AllocateDeliveryV2(retired.Deliveries, scope, CheckpointSubmissionReceipt, bAttempt) - if err != nil { - t.Fatal(err) - } - receiptTx := template[2].Transition - receiptTx.AttemptID = bAttempt - receipt := nextCheckpointV4(t, b, receiptTx) - receipt.Deliveries, err = AdvanceDeliveryV2(b.Deliveries, bAttempt, DeliveryAccepted, nil) - if err != nil { - t.Fatal(err) - } - receipt.Deliveries, err = AllocateDeliveryV2(receipt.Deliveries, scope, CheckpointSubmissionCandidate, receiptTx.NextAttemptID) + 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, a, retired, b, receipt}) + 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) } - turn := index.Turns[0] - if len(turn.Outbounds) != 2 || turn.Outbounds[0].Pair != bPair || turn.Outbounds[1].Pair != *a.Transition.Record || turn.Outbounds[0].CheckpointSequence != 3 || turn.InputReceipt.AttemptID != bAttempt { - t.Fatalf("retry facts lost: %+v", turn) - } - if !reflect.DeepEqual(turn.Scope, scope) { - t.Fatal("turn scope changed") + 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) } - // Retirement/reallocation alone does not imply a new input packet. - replacement := strings.Repeat("ef", 16) - retired.Transition.NextAttemptID = replacement - retired.Deliveries, err = AllocateDeliveryV2(retired.Deliveries, scope, CheckpointSubmissionReceipt, replacement) - if err != nil { - t.Fatal(err) - } - trust, head = storeCommitmentSequenceV4(t, d, db, ds, root, []CheckpointV4{initial, a, retired}) - _, reallocated, err := InspectStoredCheckpointV4(trust, root, head) - if err != nil || len(reallocated.Turns) != 1 || len(reallocated.Turns[0].Outbounds) != 1 || reallocated.Turns[0].Outbounds[0].Pair != *a.Transition.Record { - t.Fatal("reallocation changed published packet", err) - } } func TestTurnCommitmentBoundsV4(t *testing.T) { @@ -124,29 +93,12 @@ func TestTurnCommitmentBoundsV4(t *testing.T) { 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); err != nil { t.Fatal(err) } } if err := collectTurnCommitmentV4(turns, tx); err == nil { - t.Fatal("outbound bound not enforced") - } - turns = map[ContributionScope]*TurnCommitmentV4{} - for _, phase := range []Phase{Phase1, Phase2} { - for n := 1; n <= MaxParticipants; n++ { - scope := *tx.Transition.Scope - scope.Phase = phase - scope.Index = uint8(n) - tx.Transition.Scope = &scope - if err := collectTurnCommitmentV4(turns, tx); err != nil { - t.Fatal(err) - } - } - } - scope := *tx.Transition.Scope - scope.ParticipantID = "extra" - tx.Transition.Scope = &scope - if err := collectTurnCommitmentV4(turns, tx); err == nil { - t.Fatal("turn capacity not enforced") + t.Fatal("allocation bound not enforced") } } diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index d9bcd350..b6bda1c5 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -136,8 +136,7 @@ func (r *checkpointReaderV4) pair(refs SignedArtifactRefs) ([]byte, []byte, erro type checkpointAncestryV4 struct { head CheckpointV4 - outbound map[string]SignedArtifactRefs - receipts map[ContributionScope]SignedArtifactRefs + allocations map[string]CheckpointTransitionV4 enrollments []SignedArtifactRefs enrollmentTransitions []CheckpointTransitionV4 mirrors []SignedArtifactRefs @@ -154,7 +153,7 @@ type checkpointAncestryV4 struct { } func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, definitionBytes, definitionSignature []byte, refs SignedArtifactRefs) (checkpointAncestryV4, error) { - result := checkpointAncestryV4{outbound: map[string]SignedArtifactRefs{}, receipts: map[ContributionScope]SignedArtifactRefs{}, accepted: map[ContributionScope]SignedArtifactRefs{}, acceptedTransitions: map[ContributionScope]CheckpointTransitionV4{}, turnCommitments: map[ContributionScope]*TurnCommitmentV4{}} + 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 { @@ -220,11 +219,11 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, result.enrollments = append(result.enrollments, *current.Transition.Record) result.enrollmentTransitions = append(result.enrollmentTransitions, current.Transition) } - if current.Transition.Kind == CheckpointPhase1ReceiptAccepted || current.Transition.Kind == CheckpointPhase2ReceiptAccepted { - result.receipts[*current.Transition.Scope] = *current.Transition.Record - } - if current.Transition.Kind == CheckpointPhase1OutboundPublished || current.Transition.Kind == CheckpointPhase2OutboundPublished { - result.outbound[current.Transition.Record.Record.Digest.SHA256] = *current.Transition.Record + 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 @@ -322,8 +321,7 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { } defer reader.root.Close() var previous *CheckpointV4 - outbound := map[string]SignedArtifactRefs{} - receipts := map[ContributionScope]SignedArtifactRefs{} + allocations := map[string]CheckpointTransitionV4{} enrollments := []SignedArtifactRefs{} var evidenceAncestry checkpointAncestryV4 if c.PreviousCheckpoint != nil { @@ -332,8 +330,7 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { return nil, err } previous = &ancestry.head - outbound = ancestry.outbound - receipts = ancestry.receipts + allocations = ancestry.allocations enrollments = ancestry.enrollments evidenceAncestry = ancestry if err := ValidateCheckpointTransitionV4(*previous, c); err != nil { @@ -413,9 +410,9 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { } } } - if c.Transition.Kind == CheckpointPhase1OutboundPublished || c.Transition.Kind == CheckpointPhase2OutboundPublished { + 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 outbound delivery") + return nil, errors.New("participant enrollment must be committed before candidate allocation") } } if c.Transition.Kind == CheckpointWitnessRecorded || c.Transition.Kind == CheckpointBeaconEvidenceRecorded || c.Transition.Kind == CheckpointPhase1Sealed || c.Transition.Kind == CheckpointFinalCandidateRecorded { @@ -449,13 +446,13 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { return nil, errors.New("verified multi-relay beacon evidence is required for this phase") } } - if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, outbound, receipts); err != nil { + 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, outbound map[string]SignedArtifactRefs, receipts map[ContributionScope]SignedArtifactRefs) error { +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 { @@ -465,13 +462,10 @@ func verifyCheckpointEvidenceV4(options CheckpointPreparationV4, trusted *Truste return err } return verifyV4ChainProjection(chain, refs, c.Progress.Phase1) - case CheckpointPhase1OutboundPublished, CheckpointPhase2OutboundPublished: - _, err := verifyOutboundHandoffV4(reader, d, *previous, *c.Transition.Scope, *c.Transition.Record) - return err - case CheckpointPhase1ReceiptAccepted, CheckpointPhase2ReceiptAccepted: - return verifyOutboundReceiptV4(reader, d, *previous, c.Transition, outbound) + case CheckpointPhase1CandidateAllocated, CheckpointPhase2CandidateAllocated: + return verifyCandidateAllocationV4(d, *previous, c.Transition) case CheckpointPhase1CandidateAccepted, CheckpointPhase2CandidateAccepted: - return verifyAcceptedCandidateV4(options, trusted, reader, *previous, outbound, receipts) + return verifyAcceptedCandidateV4(options, trusted, reader, *previous, allocations) case CheckpointContributionRejected: return verifyRejectedInventoryV4(options.RejectedCandidateDir, *c.Transition.Contribution) case CheckpointDeliveryRetired, CheckpointDeliveryReallocated: @@ -526,7 +520,7 @@ func verifyRejectedInventoryV4(dir string, inventory CandidateInventory) error { return nil } -func verifyAcceptedCandidateV4(options CheckpointPreparationV4, trusted *TrustedCeremony, reader *checkpointReaderV4, previous CheckpointV4, outbound map[string]SignedArtifactRefs, receipts map[ContributionScope]SignedArtifactRefs) error { +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 @@ -574,22 +568,14 @@ func verifyAcceptedCandidateV4(options CheckpointPreparationV4, trusted *Trusted if last.ParticipantID != scope.ParticipantID { return errors.New("accepted chain names another participant") } - if err := verifyReturnHandoffV4(reader, trusted.Definition, scope, *c.Transition.Contribution); err != nil { - return err + allocation, ok := allocations[c.Transition.AttemptID] + if !ok || allocation.Scope == nil || *allocation.Scope != scope { + return errors.New("candidate has no matching authenticated allocation") } - return verifyCandidateCustodyV4(reader, trusted, previous, c.Transition, chain, outbound, receipts) + return verifyCandidateChronologyV4(reader, allocation, c.Transition, chain) } -func verifyCandidateCustodyV4(reader *checkpointReaderV4, trusted *TrustedCeremony, previous CheckpointV4, tx CheckpointTransitionV4, chain Chain, outbound map[string]SignedArtifactRefs, receipts map[ContributionScope]SignedArtifactRefs) error { - scope := *tx.Scope - inputRefs, ok := receipts[scope] - if !ok { - return errors.New("candidate has no committed outbound receipt") - } - inputTx := CheckpointTransitionV4{Scope: &scope, Record: &inputRefs} - if err := verifyOutboundReceiptV4(reader, trusted.Definition, previous, inputTx, outbound); err != nil { - return err - } +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 { @@ -597,59 +583,16 @@ func verifyCandidateCustodyV4(reader *checkpointReaderV4, trusted *TrustedCeremo } return UnmarshalCanonical(b, out) } - var inputReceipt TransferReceipt - if err := read(inputRefs.Record, &inputReceipt); err != nil { - return err - } - var inputHandoff TransferHandoff - if err := read(outbound[inputReceipt.HandoffSHA256].Record, &inputHandoff); err != nil { - return err - } - base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) - get := func(name string) ArtifactRef { - for _, ref := range tx.Evidence { - if ref.Name == base+name { - return ref - } - } - return ArtifactRef{} - } - handoffBytes, err := reader.read(get("return-handoff.json"), maxSignedRecordBytes, true) - if err != nil { - return err - } - var handoff TransferHandoff - if err = UnmarshalCanonical(handoffBytes, &handoff); err != nil { - return err - } - rb, rs, err := reader.pair(SignedArtifactRefs{Record: get("return-receipt.json"), Signature: get("return-receipt.sig")}) - if err != nil { - return err - } - var receipt TransferReceipt - if err = VerifySignedRecord(rb, rs, &receipt, trusted.Definition.Coordinator.KeyID, trusted.CoordinatorPublicKey); err != nil { - return err - } - if receipt.Kind != ReceiptReceiver { - return errors.New("return receipt must be the coordinator receiver receipt") - } - if err = VerifyTransferReceipt(handoffBytes, handoff, receipt); err != nil { - return err - } last := chain.Records[len(chain.Records)-1] var attestation ContributionAttestation var erasure ErasureAttestation - if err = read(last.Attestation, &attestation); err != nil { + if err := read(last.Attestation, &attestation); err != nil { return err } - if err = read(last.Erasure, &erasure); err != nil { + if err := read(last.Erasure, &erasure); err != nil { return err } - predecessor := trusted.Definition.CreatedAt - if len(chain.Records) > 1 { - predecessor = chain.Records[len(chain.Records)-2].AcceptedAt - } - timestamps := []string{predecessor, inputHandoff.CreatedAt, inputReceipt.ReceivedAt, attestation.ContributedAt, erasure.DestroyedAt, handoff.CreatedAt, receipt.ReceivedAt, last.AcceptedAt} + 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) @@ -657,15 +600,32 @@ func verifyCandidateCustodyV4(reader *checkpointReaderV4, trusted *TrustedCeremo return err } // Existing cleanup validation permits the same recorded timestamp as - // contribution; custody handoffs/receipts must be strictly later. - if i > 0 && ((i == 4 && parsed.Before(before)) || (i != 4 && !parsed.After(before))) { - return errors.New("candidate custody, cleanup and acceptance timestamps are not strictly ordered") + // 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 { diff --git a/internal/mpcceremony/checkpoint_v4_governance_test.go b/internal/mpcceremony/checkpoint_v4_governance_test.go index b1ab37f8..220ee96b 100644 --- a/internal/mpcceremony/checkpoint_v4_governance_test.go +++ b/internal/mpcceremony/checkpoint_v4_governance_test.go @@ -12,7 +12,7 @@ import ( func TestCheckpointV4TerminationPreservesActiveDeliveries(t *testing.T) { d, genesis, _, _ := checkpointFixtureV4(t) turn := checkpointTurnV4(t, d, genesis, Phase1) - for _, start := range []CheckpointV4{genesis, turn[1], turn[2], turn[3]} { + 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"))} @@ -47,7 +47,7 @@ func TestCheckpointV4TerminationPreservesActiveDeliveries(t *testing.T) { 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, CheckpointPhase1OutboundPublished, CheckpointFinalReleaseRecorded} { + for _, later := range []CheckpointTransitionKind{CheckpointIncidentRecorded, CheckpointAborted, CheckpointRestarted, CheckpointEnrollmentRecorded, CheckpointPhase1Closed, CheckpointPhase1CandidateAllocated, CheckpointFinalReleaseRecorded} { child := nextCheckpointV4(t, next, tx) child.Transition.Kind = later child.Progress.Terminal = nil diff --git a/internal/mpcceremony/checkpoint_v4_test.go b/internal/mpcceremony/checkpoint_v4_test.go index 6015c6f4..f2a343cc 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -76,36 +76,23 @@ func checkpointTurnV4(t *testing.T, d CeremonyDefinition, start CheckpointV4, ph t.Helper() state := start.Progress.Phase1 participant := d.Phase1Policy.Participants[0] - outbound, receipt, accept := CheckpointPhase1OutboundPublished, CheckpointPhase1ReceiptAccepted, CheckpointPhase1CandidateAccepted + allocate, accept := CheckpointPhase1CandidateAllocated, CheckpointPhase1CandidateAccepted if phase == Phase2 { state = *start.Progress.Phase2 participant = d.Phase2Policy.Participants[0] - outbound = CheckpointPhase2OutboundPublished - receipt = CheckpointPhase2ReceiptAccepted + allocate = CheckpointPhase2CandidateAllocated accept = CheckpointPhase2CandidateAccepted } scope := ContributionScope{CeremonyID: d.CeremonyID, Phase: phase, Index: state.AcceptedCount + 1, ParticipantID: participant, ParentHeadID: state.HeadRecordID} - id1, id2 := fmt.Sprintf("%032x", start.Sequence+100), fmt.Sprintf("%032x", start.Sequence+101) - handoff := checkpointSigned(string(phase) + "/outbound") - c1 := nextCheckpointV4(t, start, CheckpointTransitionV4{Kind: outbound, Scope: &scope, AttemptID: id1, Record: &handoff, Evidence: []ArtifactRef{}}) + 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, CheckpointSubmissionReceipt, id1) - if err != nil { - t.Fatal(err) - } - r := checkpointSigned(string(phase) + "/receipt") - c2 := nextCheckpointV4(t, c1, CheckpointTransitionV4{Kind: receipt, Scope: &scope, AttemptID: id1, NextAttemptID: id2, Record: &r, Evidence: []ArtifactRef{}}) - c2.Deliveries, err = AdvanceDeliveryV2(c1.Deliveries, id1, DeliveryAccepted, nil) - if err != nil { - t.Fatal(err) - } - c2.Deliveries, err = AllocateDeliveryV2(c2.Deliveries, scope, CheckpointSubmissionCandidate, id2) + c1.Deliveries, err = AllocateDeliveryV2(start.Deliveries, scope, CheckpointSubmissionCandidate, id) if err != nil { t.Fatal(err) } _, inventory := candidateInventoryFixture(t) inventory.Scope = scope - inventory.Files = append(inventory.Files, inventoryTestRef("return-handoff.json", []byte("handoff")), inventoryTestRef("return-handoff.sig", []byte("handoff signature"))) chain := checkpointSigned(fmt.Sprintf("%s/chain-%04d", phase, scope.Index)) evidence := []ArtifactRef{} for _, ref := range inventory.Files { @@ -113,19 +100,18 @@ func checkpointTurnV4(t *testing.T, d CeremonyDefinition, start CheckpointV4, ph evidence = append(evidence, ref) } evidence = checkpointArtifacts(append(evidence, checkpointArtifact(fmt.Sprintf("%s/contributions/%04d/verification.json", phase, scope.Index), "verification"))...) - evidence = checkpointArtifacts(append(evidence, checkpointArtifact(fmt.Sprintf("%s/contributions/%04d/return-receipt.json", phase, scope.Index), "receipt"), checkpointArtifact(fmt.Sprintf("%s/contributions/%04d/return-receipt.sig", phase, scope.Index), "receipt signature"))...) - c3 := nextCheckpointV4(t, c2, CheckpointTransitionV4{Kind: accept, Scope: &scope, AttemptID: id2, Record: &chain, Evidence: evidence, Contribution: &inventory}) - c3.Deliveries, err = AdvanceDeliveryV2(c2.Deliveries, id2, DeliveryAccepted, &inventory) + 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 { - c3.Progress.Phase1 = nextState + c2.Progress.Phase1 = nextState } else { - c3.Progress.Phase2 = &nextState + c2.Progress.Phase2 = &nextState } - sequence := []CheckpointV4{start, c1, c2, c3} + 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) @@ -225,13 +211,15 @@ func TestCheckpointV4RejectsSkippedOrAlteredTurnEdges(t *testing.T) { "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")) }, - "five file candidate": func(n *CheckpointV4) { n.Transition.Contribution.Files = n.Transition.Contribution.Files[:5] }, - "missing return receipt": func(n *CheckpointV4) { + "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, "return-receipt.sig") { + if strings.HasSuffix(ref.Name, "verification.json") { n.Transition.Evidence = append(n.Transition.Evidence[:i], n.Transition.Evidence[i+1:]...) break } @@ -241,23 +229,22 @@ func TestCheckpointV4RejectsSkippedOrAlteredTurnEdges(t *testing.T) { "advance another phase": func(n *CheckpointV4) { n.Progress.Phase2 = &n.Progress.Phase1 }, } { t.Run(name, func(t *testing.T) { - n := cloneCheckpointV4(t, turn[3]) + n := cloneCheckpointV4(t, turn[2]) mutate(&n) - if err := ValidateCheckpointTransitionV4(turn[2], n); err == nil { + if err := ValidateCheckpointTransitionV4(turn[1], n); err == nil { t.Fatal("invalid edge accepted") } }) } - if err := ValidateCheckpointTransitionV4(turn[1], turn[3]); err == nil { - t.Fatal("receipt step skipped") + if err := ValidateCheckpointTransitionV4(turn[0], turn[2]); err == nil { + t.Fatal("allocation step skipped") } - // A receipt allocation cannot be treated as an accepted candidate allocation. - n := cloneCheckpointV4(t, turn[3]) - n.Sequence = turn[1].Sequence + 1 - raw, _ := MarshalCanonical(turn[1]) + 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[1], n); err == nil { - t.Fatal("candidate accepted before receipt") + if err := ValidateCheckpointTransitionV4(turn[0], n); err == nil { + t.Fatal("candidate accepted before allocation") } } @@ -316,14 +303,14 @@ func TestCheckpointV4RejectsOverlappingArtifactNames(t *testing.T) { func TestCheckpointV4DeliveryRetryAndRejectionEdges(t *testing.T) { d, initial, _, _ := checkpointFixtureV4(t) turn := checkpointTurnV4(t, d, initial, Phase1) - previous := turn[2] + previous := turn[1] for _, kind := range []CheckpointTransitionKind{CheckpointDeliveryRetired, CheckpointContributionRejected} { t.Run(string(kind), func(t *testing.T) { - transition := CheckpointTransitionV4{Kind: kind, Scope: turn[3].Transition.Scope, AttemptID: turn[3].Transition.AttemptID, NextAttemptID: strings.Repeat("e", 32), Evidence: []ArtifactRef{}} + 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[3].Transition.Contribution + transition.Contribution = turn[2].Transition.Contribution } next := nextCheckpointV4(t, previous, transition) var err error @@ -346,10 +333,10 @@ func TestCheckpointV4DeliveryRetryAndRejectionEdges(t *testing.T) { t.Fatal("rejection silently retired") } } - accept := turn[3].Transition + accept := turn[2].Transition accept.AttemptID = transition.NextAttemptID final := nextCheckpointV4(t, next, accept) - final.Progress = turn[3].Progress + final.Progress = turn[2].Progress final.Deliveries, err = AdvanceDeliveryV2(next.Deliveries, accept.AttemptID, DeliveryAccepted, accept.Contribution) if kind == CheckpointContributionRejected { if err == nil { @@ -384,13 +371,12 @@ func TestCheckpointV4DeliveryRetryAndRejectionEdges(t *testing.T) { func TestCheckpointV4RetirementAtLimitCanCloseAfterMinimum(t *testing.T) { d, initial, db, ds := checkpointFixtureV4(t) turn := checkpointTurnV4(t, d, initial, Phase1) - previous := turn[3] + 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) - handoff := checkpointSigned("phase1/outbound-2") - c := nextCheckpointV4(t, previous, CheckpointTransitionV4{Kind: CheckpointPhase1OutboundPublished, Scope: &scope, AttemptID: id, Record: &handoff, Evidence: []ArtifactRef{}}) + 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, CheckpointSubmissionReceipt, id) + c.Deliveries, err = AllocateDeliveryV2(previous.Deliveries, scope, CheckpointSubmissionCandidate, id) if err != nil { t.Fatal(err) } @@ -411,7 +397,7 @@ func TestCheckpointV4RetirementAtLimitCanCloseAfterMinimum(t *testing.T) { 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, CheckpointSubmissionReceipt, nextID) + n.Deliveries, err = AllocateDeliveryV2(c.Deliveries, scope, CheckpointSubmissionCandidate, nextID) if err != nil { t.Fatal(err) } @@ -422,7 +408,7 @@ func TestCheckpointV4RetirementAtLimitCanCloseAfterMinimum(t *testing.T) { id = nextID } } - if _, err := AllocateDeliveryV2(c.Deliveries, scope, CheckpointSubmissionReceipt, strings.Repeat("f", 32)); err == nil { + if _, err := AllocateDeliveryV2(c.Deliveries, scope, CheckpointSubmissionCandidate, strings.Repeat("f", 32)); err == nil { t.Fatal("attempt budget exceeded") } closure := checkpointSigned("phase1/closure") diff --git a/internal/mpcceremony/checkpoint_v4_turn.go b/internal/mpcceremony/checkpoint_v4_turn.go new file mode 100644 index 00000000..5d3c22cd --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_turn.go @@ -0,0 +1,255 @@ +package mpcceremony + +import ( + "errors" + "fmt" + "path/filepath" + "slices" + "sort" +) + +// 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 +} + +// 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))} + accept := AcceptContributionFilesOptions{Trust: options.Trust, Circuit: options.Circuit, Phase: scope.Phase, Transcript: paths, CandidateDir: options.CandidateDir, CoordinatorPrivateKeyPath: options.CoordinatorPrivateKeyPath, AcceptedAt: options.AcceptedAt} + 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) + 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/contribution_allocation_v4.go b/internal/mpcceremony/contribution_allocation_v4.go new file mode 100644 index 00000000..a7ee10cc --- /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 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 index 67c81878..32ecdf16 100644 --- a/internal/mpcceremony/contribution_inventory_v4.go +++ b/internal/mpcceremony/contribution_inventory_v4.go @@ -2,7 +2,6 @@ package mpcceremony import ( "errors" - "fmt" "io" "os" "time" @@ -20,8 +19,8 @@ type ContributionInventoryInspectionV4 struct { CandidateResultID string `json:"candidate_result_id,omitempty"` } -// InspectContributionInventoryV4 reconstructs the five-file computation and, -// if present, its seven-file return package together. expected must come from +// 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) { @@ -101,37 +100,7 @@ func inspectContributionInventoryV4(r *checkpointReaderV4, d CeremonyDefinition, if err != nil { return zero, err } - result := ContributionInventoryInspectionV4{Scope: scope, Computed: computed, ComputedCandidateID: id} - record, recordErr := readLocalInventoryRecordV4(r, "return-handoff.json", maxSignedRecordBytes) - sig, sigErr := readLocalInventoryRecordV4(r, "return-handoff.sig", 4096) - if errors.Is(recordErr, os.ErrNotExist) && errors.Is(sigErr, os.ErrNotExist) { - return result, nil - } - if recordErr != nil { - return zero, fmt.Errorf("return handoff: %w", recordErr) - } - if sigErr != nil { - return zero, fmt.Errorf("return handoff signature: %w", sigErr) - } - complete := CandidateInventory{Schema: computed.Schema, Scope: scope, Files: append(append([]ArtifactRef{}, computed.Files...), ArtifactRef{Name: "return-handoff.json", Digest: NewDigest(record)}, ArtifactRef{Name: "return-handoff.sig", Digest: NewDigest(sig)})} - if err := verifyReturnHandoffBytesV4(d, scope, complete, record, sig); err != nil { - return zero, err - } - var handoff TransferHandoff - if err := UnmarshalCanonical(record, &handoff); err != nil { - return zero, err - } - destroyed, _ := time.Parse(time.RFC3339Nano, erasure.DestroyedAt) - created, _ := time.Parse(time.RFC3339Nano, handoff.CreatedAt) - if !created.After(destroyed) { - return zero, errors.New("return handoff must be created strictly after cleanup") - } - result.CandidateResultID, err = complete.ID() - if err != nil { - return zero, err - } - result.Complete = &complete - return result, nil + return ContributionInventoryInspectionV4{Scope: scope, Computed: computed, ComputedCandidateID: id, Complete: &computed, CandidateResultID: id}, nil } // Only fixed basenames reach this helper. os.Root confines resolution, and diff --git a/internal/mpcceremony/contribution_inventory_v4_test.go b/internal/mpcceremony/contribution_inventory_v4_test.go index 71196918..8461dc9f 100644 --- a/internal/mpcceremony/contribution_inventory_v4_test.go +++ b/internal/mpcceremony/contribution_inventory_v4_test.go @@ -90,21 +90,7 @@ func (f inventoryFixtureV4) inspect() (ContributionInventoryInspectionV4, error) return InspectContributionInventoryV4(f.trust, f.paths, f.scope, f.dir) } -func (f inventoryFixtureV4) returnPair(t *testing.T, inventory CandidateInventory) SignedArtifactRefs { - t.Helper() - files := append([]ArtifactRef{}, inventory.Files...) - for i := range files { - files[i].Name = fmt.Sprintf("%s/contributions/%04d/%s", f.scope.Phase, f.scope.Index, files[i].Name) - } - p := f.d.Roster[int(f.scope.Index)-1].Identity - h, err := NewTransferHandoff(f.d, f.scope.Phase, f.scope.Index, f.scope.ParentHeadID, files, p, f.d.Coordinator, "2026-07-23T12:03:00Z", "2026-07-23T13:03:00Z") - if err != nil { - t.Fatal(err) - } - return putCheckpointTestPairV4(t, f.dir, "return-handoff", h, p.KeyID, adversarialPrivateKey(0x10+f.scope.Index)) -} - -func TestContributionInventoryV4ReconstructsFiveAndSevenFiles(t *testing.T) { +func TestContributionInventoryV4ReconstructsFixedFiveFiles(t *testing.T) { for _, phase := range []Phase{Phase1, Phase2} { t.Run(string(phase), func(t *testing.T) { f := localInventoryFixtureV4(t, phase) @@ -112,20 +98,12 @@ func TestContributionInventoryV4ReconstructsFiveAndSevenFiles(t *testing.T) { if err != nil { t.Fatal(err) } - if len(five.Computed.Files) != 5 || five.ComputedCandidateID == "" || five.Complete != nil || five.CandidateResultID != "" || five.Scope != f.scope { + 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) } - f.returnPair(t, five.Computed) - seven, err := f.inspect() - if err != nil { - t.Fatal(err) - } - if seven.Complete == nil || len(seven.Complete.Files) != 7 || seven.ComputedCandidateID != five.ComputedCandidateID || seven.CandidateResultID == "" || seven.CandidateResultID == five.ComputedCandidateID { - t.Fatalf("bad complete result %+v", seven) - } putCheckpointTestFileV4(t, f.dir, "local-metadata.json", []byte("not uploaded")) again, err := f.inspect() - if err != nil || again.CandidateResultID != seven.CandidateResultID { + if err != nil || again.CandidateResultID != five.CandidateResultID { t.Fatal("extra file changed inventory", err) } }) @@ -133,10 +111,10 @@ func TestContributionInventoryV4ReconstructsFiveAndSevenFiles(t *testing.T) { } func TestContributionInventoryV4RejectsPartialChangedAndUnboundWork(t *testing.T) { - for _, test := range []string{"scope", "phase", "participant", "software", "time", "payload", "partial", "return-signature", "changed-five", "symlink", "oversize"} { + for _, test := range []string{"scope", "phase", "participant", "software", "time", "payload", "symlink", "oversize"} { t.Run(test, func(t *testing.T) { f := localInventoryFixtureV4(t, Phase1) - five, err := f.inspect() + _, err := f.inspect() if err != nil { t.Fatal(err) } @@ -157,16 +135,6 @@ func TestContributionInventoryV4RejectsPartialChangedAndUnboundWork(t *testing.T f.sign(t, a) case "payload": putCheckpointTestFileV4(t, f.dir, "contribution.bin", []byte("changed")) - case "partial": - putCheckpointTestFileV4(t, f.dir, "return-handoff.json", []byte("{}")) - case "return-signature": - f.returnPair(t, five.Computed) - putCheckpointTestFileV4(t, f.dir, "return-handoff.sig", []byte("{}")) - case "changed-five": - f.returnPair(t, five.Computed) - a := f.a - a.ContributedAt = "2026-07-23T12:01:01Z" - f.sign(t, a) case "symlink": if err := os.Rename(filepath.Join(f.dir, "attestation.json"), filepath.Join(f.dir, "other.json")); err != nil { t.Fatal(err) @@ -193,45 +161,6 @@ func TestContributionInventoryV4RejectsPartialChangedAndUnboundWork(t *testing.T } } -func TestReturnHandoffV4RejectsFiveFileInventoryWithoutPanic(t *testing.T) { - f := localInventoryFixtureV4(t, Phase1) - i, err := f.inspect() - if err != nil { - t.Fatal(err) - } - if err := verifyReturnHandoffBytesV4(f.d, f.scope, i.Computed, nil, nil); err == nil { - t.Fatal("accepted incomplete inventory") - } -} - -func TestContributionInventoryV4ReturnMustFollowCleanup(t *testing.T) { - for _, phase := range []Phase{Phase1, Phase2} { - for _, created := range []string{"2026-07-23T12:01:59Z", "2026-07-23T12:02:00Z"} { - t.Run(string(phase)+created, func(t *testing.T) { - f := localInventoryFixtureV4(t, phase) - five, err := f.inspect() - if err != nil { - t.Fatal(err) - } - pair := f.returnPair(t, five.Computed) - b, err := os.ReadFile(filepath.Join(f.dir, pair.Record.Name)) - if err != nil { - t.Fatal(err) - } - var h TransferHandoff - if err := UnmarshalCanonical(b, &h); err != nil { - t.Fatal(err) - } - h.CreatedAt = created - putCheckpointTestPairV4(t, f.dir, "return-handoff", h, f.d.Roster[0].Identity.KeyID, adversarialPrivateKey(0x11)) - if _, err := f.inspect(); err == nil || !strings.Contains(err.Error(), "strictly after cleanup") { - t.Fatal(err) - } - }) - } - } -} - func TestContributionInventoryV4LaterTurnAndPredecessorTime(t *testing.T) { for _, phase := range []Phase{Phase1, Phase2} { t.Run(string(phase), func(t *testing.T) { @@ -271,9 +200,8 @@ func TestContributionInventoryV4LaterTurnAndPredecessorTime(t *testing.T) { if err != nil { t.Fatal(err) } - f.returnPair(t, i.Computed) - if _, err := f.inspect(); 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) diff --git a/internal/mpcceremony/delivery_scope.go b/internal/mpcceremony/delivery_scope.go index fc895428..c5b7dfc7 100644 --- a/internal/mpcceremony/delivery_scope.go +++ b/internal/mpcceremony/delivery_scope.go @@ -75,11 +75,8 @@ func (c CandidateInventory) Validate() error { return err } expected := []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} - if len(c.Files) == 7 { - expected = append(expected, "return-handoff.json", "return-handoff.sig") - } if len(c.Files) != len(expected) { - return errors.New("candidate requires contribution, signed attestation, signed cleanup, and either both return-handoff files or neither") + 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 { @@ -91,7 +88,7 @@ func (c CandidateInventory) Validate() error { limit := int64(maxSignedRecordBytes) if ref.Name == "contribution.bin" { limit = MaxArtifactSize - } else if ref.Name == "attestation.sig" || ref.Name == "erasure.sig" || ref.Name == "return-handoff.sig" { + } else if ref.Name == "attestation.sig" || ref.Name == "erasure.sig" { limit = 4096 } if ref.Digest.Size <= 0 || ref.Digest.Size > limit { diff --git a/internal/mpcceremony/delivery_scope_test.go b/internal/mpcceremony/delivery_scope_test.go index bc6abdd4..be209716 100644 --- a/internal/mpcceremony/delivery_scope_test.go +++ b/internal/mpcceremony/delivery_scope_test.go @@ -60,8 +60,8 @@ func TestContributionResultIDBindsCompleteScopedInventory(t *testing.T) { 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 || got == want { - t.Fatalf("return custody not bound: %s %v", got, err) + 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)} { diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index fbf917cc..e267420f 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -13,6 +13,7 @@ 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 { @@ -37,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) } @@ -58,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) @@ -79,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"` @@ -90,7 +97,7 @@ type PhaseOperationalEvidence struct { RawBeaconResponses []ArtifactRef `json:"raw_beacon_responses"` } -func (p PhaseOperationalEvidence) Validate() error { +func (p PhaseOperationalEvidence) validate(custodyRequired bool) error { if err := p.Phase.Validate(); err != nil { return err } @@ -104,7 +111,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) { @@ -130,6 +137,8 @@ func (p PhaseOperationalEvidence) Validate() error { return nil } +func (p PhaseOperationalEvidence) Validate() error { return p.validate(true) } + // 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 @@ -151,14 +160,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 { @@ -192,19 +201,20 @@ func (b OperationalEvidenceBundle) Validate() error { return err } } - if err := b.Phase1.Validate(); err != nil { + custodyRequired := b.Schema != OperationalEvidenceBundleSchemaV4 + if err := b.Phase1.validate(custodyRequired); 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); 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) @@ -334,8 +344,12 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, } expectedAssurance := defaultAssurancePolicy(options.Definition.Mode) if options.Definition.UsesSignedAssurancePolicy() { - if bundle.Schema != OperationalEvidenceBundleSchema { - return VerifiedOperationalEvidence{}, errors.New("definition v3 requires operational evidence bundle v3") + 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 { @@ -491,27 +505,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) @@ -905,6 +921,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 || @@ -1013,125 +1030,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/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 60e6a27c..6dd2de7c 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -141,77 +141,40 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com return err } scope := m.ContributionScope{CeremonyID: d.CeremonyID, Phase: m.Phase1, Index: 1, ParticipantID: p.ID, ParentHeadID: head} - handoff, err := m.NewTransferHandoff(d, m.Phase1, 1, head, []m.ArtifactRef{payload}, d.Coordinator, p, "2023-08-23T15:01:00Z", "2023-08-23T16:01:00Z") - if err != nil { - return err - } - handoffRefs, err := writePair("custody/outbound", handoff, d.Coordinator.KeyID, coordinator) - if err != nil { - return err - } - const first = "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa" - const second = "bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb" const candidateAttempt = "cccccccccccccccccccccccccccccccc" - next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1OutboundPublished, Scope: &scope, AttemptID: first, Record: &handoffRefs, Evidence: []m.ArtifactRef{}}) - c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionReceipt, first) + 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 = sorted(append(append([]m.ArtifactRef{}, beforeEnrollment.AcceptedArtifacts...), handoffRefs.Record, handoffRefs.Signature)) + 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("outbound without committed enrollment: %v", err) - } - if err = commit(); err != nil { - return err - } - // Retire without replacement, then reallocate. The receipt still binds the - // original signed handoff, not a transport-attempt envelope. - next(m.CheckpointTransitionV4{Kind: m.CheckpointDeliveryRetired, Scope: &scope, AttemptID: first, Evidence: []m.ArtifactRef{}}) - c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, first, m.DeliveryRetired, nil) - if err != nil { - return err - } - if err = commit(); err != nil { - return err - } - next(m.CheckpointTransitionV4{Kind: m.CheckpointDeliveryReallocated, Scope: &scope, AttemptID: first, NextAttemptID: second, Evidence: []m.ArtifactRef{}}) - c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionReceipt, second) - if err != nil { - return err - } - if err = commit(); err != nil { - return err - } - hb, err := os.ReadFile(filepath.Join(root, handoffRefs.Record.Name)) - if err != nil { - return err + return fmt.Errorf("allocation without committed enrollment: %v", err) } - receipt, err := m.NewTransferReceipt(handoff, hb, m.ReceiptReceiver, "2023-08-23T15:02:00Z") + 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 } - receiptRefs, err := writePair("custody/receipt", receipt, p.KeyID, participant) - if err != nil { - return err - } - next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1ReceiptAccepted, Scope: &scope, AttemptID: second, NextAttemptID: candidateAttempt, Record: &receiptRefs, Evidence: []m.ArtifactRef{}}) - c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, second, m.DeliveryAccepted, nil) - if err != nil { - return err - } - c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionCandidate, candidateAttempt) - 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} - if _, err = m.CreateContributionCandidate(m.ContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase1, Transcript: paths, ParticipantID: p.ID, ParticipantPrivateKeyPath: participantPath, Environment: environment, ContributedAt: "2023-08-23T15:03:00Z", CandidateDir: candidateDir}); err != nil { + 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) @@ -224,61 +187,18 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com 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 } - returnFiles := []m.ArtifactRef{} computedInventory, err := m.InspectContributionInventoryV4(trust, paths, scope, candidateDir) if err != nil { return err } - if computedInventory.Complete != nil || computedInventory.ComputedCandidateID == "" { + if computedInventory.Complete == nil || computedInventory.ComputedCandidateID == "" || computedInventory.CandidateResultID != computedInventory.ComputedCandidateID { return errors.New("computed inventory reconstruction failed") } - 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 - } - returnFiles = append(returnFiles, m.ArtifactRef{Name: "phase1/contributions/0001/" + name, Digest: m.NewDigest(b)}) - } - returnHandoff, err := m.NewTransferHandoff(d, m.Phase1, 1, scope.ParentHeadID, returnFiles, p, d.Coordinator, "2023-08-23T15:04:10Z", "2023-08-23T16:04:10Z") - if err != nil { - return err - } - returnRefs, err := writePair("custody/return-handoff", returnHandoff, p.KeyID, participant) - if err != nil { - return err - } - rhBytes, err := os.ReadFile(filepath.Join(root, returnRefs.Record.Name)) - if err != nil { - return err - } - returnReceipt, err := m.NewTransferReceipt(returnHandoff, rhBytes, m.ReceiptReceiver, "2023-08-23T15:04:20Z") - if err != nil { - return err - } - returnReceiptRefs, err := writePair("custody/return-receipt", returnReceipt, d.Coordinator.KeyID, coordinator) - if err != nil { - return err - } - for _, ref := range []m.ArtifactRef{returnRefs.Record, returnRefs.Signature} { - b, err := os.ReadFile(filepath.Join(root, ref.Name)) - if err != nil { - return err - } - if err := os.WriteFile(filepath.Join(candidateDir, filepath.Base(ref.Name)), b, 0600); err != nil { - return err - } - } - completeInventory, err := m.InspectContributionInventoryV4(trust, paths, scope, candidateDir) - if err != nil { - return err - } - if completeInventory.Complete == nil || completeInventory.ComputedCandidateID != computedInventory.ComputedCandidateID || completeInventory.CandidateResultID == computedInventory.ComputedCandidateID { - return errors.New("complete inventory reconstruction failed") - } - accepted, err := m.VerifyAndAcceptContribution(m.AcceptContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase1, Transcript: paths, CandidateDir: candidateDir, CoordinatorPrivateKeyPath: coordinatorPath, AcceptedAt: "2023-08-23T15:05:00Z"}) + 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) @@ -287,35 +207,20 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com } last := chain.Records[0] files := []m.ArtifactRef{last.Attestation, last.AttestationSignature, last.OutputPayload, last.Erasure, last.ErasureSignature} - returnEvidence := []m.ArtifactRef{} - for _, pair := range []m.SignedArtifactRefs{returnRefs, returnReceiptRefs} { - for _, original := range []m.ArtifactRef{pair.Record, pair.Signature} { - b, err := os.ReadFile(filepath.Join(root, original.Name)) - if err != nil { - return err - } - name := "phase1/contributions/0001/" + filepath.Base(original.Name) - if err = os.WriteFile(filepath.Join(root, name), b, 0600); err != nil { - return err - } - returnEvidence = append(returnEvidence, m.ArtifactRef{Name: name, Digest: m.NewDigest(b)}) - } - } - files = append(files, returnEvidence[:2]...) 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 != completeInventory.CandidateResultID { + if id, err := inventory.ID(); err != nil || id != computedInventory.CandidateResultID { return errors.New("accepted inventory differs from inspected candidate") } - evidence := append(append([]m.ArtifactRef{}, files...), returnEvidence[2:]...) - evidence = append(evidence, last.Verification) - next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase1CandidateAccepted, Scope: &scope, AttemptID: candidateAttempt, Record: &chainRefs, Evidence: sorted(evidence), Contribution: &inventory}) - c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, candidateAttempt, m.DeliveryAccepted, &inventory) + 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 @@ -324,35 +229,7 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if err != nil { return err } - c.Progress.Phase1 = m.CheckpointPhaseState{Phase: m.Phase1, AcceptedCount: 1, HeadRecordID: head, HeadPayload: payload, Chain: chainRefs} - lateReceipt := returnReceipt - lateReceipt.ReceivedAt = "2023-08-23T15:06:00Z" - lateRefs, err := writePair("phase1/contributions/0001/return-receipt", lateReceipt, d.Coordinator.KeyID, coordinator) - if err != nil { - return err - } - bad := c - bad.Transition.Evidence = append([]m.ArtifactRef{}, c.Transition.Evidence...) - bad.AcceptedArtifacts = append([]m.ArtifactRef{}, c.AcceptedArtifacts...) - for _, replacement := range []m.ArtifactRef{lateRefs.Record, lateRefs.Signature} { - for i := range bad.Transition.Evidence { - if bad.Transition.Evidence[i].Name == replacement.Name { - bad.Transition.Evidence[i] = replacement - } - } - for i := range bad.AcceptedArtifacts { - if bad.AcceptedArtifacts[i].Name == replacement.Name { - bad.AcceptedArtifacts[i] = replacement - } - } - } - _, lateErr := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: bad, Circuit: circuit}) - if lateErr == nil || !strings.Contains(lateErr.Error(), "timestamps") { - return fmt.Errorf("late signed return receipt: expected custody chronology rejection, got %v", lateErr) - } - if _, err = writePair("phase1/contributions/0001/return-receipt", returnReceipt, d.Coordinator.KeyID, coordinator); err != nil { - return err - } + c = acceptedCheckpoint.Checkpoint if err = commit(); err != nil { return err } @@ -603,6 +480,6 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if err = commit(); err != nil { return err } - fmt.Println("V4 real phase1 turn passed: initial, outbound, retirement, reallocation, receipt, contribution, cleanup, full replay, exact acceptance, corruption rejected, closure, drand, seal, phase2 genesis") + 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 index 9557178c..4af08257 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -24,51 +24,28 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co 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)} - handoff, err := m.NewTransferHandoff(d, m.Phase2, 1, scope.ParentHeadID, []m.ArtifactRef{c.Progress.Phase2.HeadPayload}, d.Coordinator, p, "2023-08-23T15:11:30.1Z", "2023-08-23T16:11:30.1Z") - if err != nil { - return err - } - hr, err := writePair("custody/phase2-outbound", handoff, d.Coordinator.KeyID, coordinator) - if err != nil { - return err - } - const receiptAttempt = "dddddddddddddddddddddddddddddddd" const candidateAttempt = "eeeeeeeeeeeeeeeeeeeeeeeeeeeeeeee" - next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2OutboundPublished, Scope: &scope, AttemptID: receiptAttempt, Record: &hr, Evidence: []m.ArtifactRef{}}) - c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionReceipt, receiptAttempt) + 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 } - hb, err := os.ReadFile(path(hr.Record)) - if err != nil { - return err - } - receipt, err := m.NewTransferReceipt(handoff, hb, m.ReceiptReceiver, "2023-08-23T15:11:30.2Z") - if err != nil { - return err - } - rr, err := writePair("custody/phase2-receipt", receipt, p.KeyID, participant) - if err != nil { - return err - } - next(m.CheckpointTransitionV4{Kind: m.CheckpointPhase2ReceiptAccepted, Scope: &scope, AttemptID: receiptAttempt, NextAttemptID: candidateAttempt, Record: &rr, Evidence: []m.ArtifactRef{}}) - c.Deliveries, err = m.AdvanceDeliveryV2(c.Deliveries, receiptAttempt, m.DeliveryAccepted, nil) + checkpointRecord, err := ref(fmt.Sprintf("checkpoints/%04d.json", c.Sequence)) if err != nil { return err } - c.Deliveries, err = m.AllocateDeliveryV2(c.Deliveries, scope, m.CheckpointSubmissionCandidate, candidateAttempt) + checkpointSignature, err := ref(fmt.Sprintf("checkpoints/%04d.sig", c.Sequence)) if err != nil { return err } - if err = commit(); 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.CreateContributionCandidate(m.ContributionFilesOptions{Trust: trust, Circuit: circuit, Phase: m.Phase2, Transcript: paths, Phase1SealPath: path(seal.Record), Phase1SealSignaturePath: path(seal.Signature), ParticipantID: p.ID, ParticipantPrivateKeyPath: participantPath, Environment: environment, ContributedAt: "2023-08-23T15:11:30.3Z", CandidateDir: candidateDir}); err != nil { + 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 { @@ -82,26 +59,6 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co } files = append(files, m.ArtifactRef{Name: "phase2/contributions/0001/" + name, Digest: m.NewDigest(b)}) } - rh, err := m.NewTransferHandoff(d, m.Phase2, 1, scope.ParentHeadID, files, p, d.Coordinator, "2023-08-23T15:11:30.41Z", "2023-08-23T16:11:30.41Z") - if err != nil { - return err - } - rhr, err := writePair("custody/phase2-return-handoff", rh, p.KeyID, participant) - if err != nil { - return err - } - b, err := os.ReadFile(path(rhr.Record)) - if err != nil { - return err - } - returnReceipt, err := m.NewTransferReceipt(rh, b, m.ReceiptReceiver, "2023-08-23T15:11:30.42Z") - if err != nil { - return err - } - rrr, err := writePair("custody/phase2-return-receipt", returnReceipt, d.Coordinator.KeyID, coordinator) - if err != nil { - return err - } 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 @@ -113,34 +70,11 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co } last := chain.Records[0] files = []m.ArtifactRef{last.Attestation, last.AttestationSignature, last.OutputPayload, last.Erasure, last.ErasureSignature} - returns := []m.ArtifactRef{} - for i, pair := range []m.SignedArtifactRefs{rhr, rrr} { - base := "return-handoff" - if i == 1 { - base = "return-receipt" - } - for j, r := range []m.ArtifactRef{pair.Record, pair.Signature} { - ext := ".json" - if j == 1 { - ext = ".sig" - } - b, err := os.ReadFile(path(r)) - if err != nil { - return err - } - name := "phase2/contributions/0001/" + base + ext - if err = os.WriteFile(filepath.Join(root, name), b, 0600); err != nil { - return err - } - returns = append(returns, m.ArtifactRef{Name: name, Digest: m.NewDigest(b)}) - } - } - files = append(files, returns[:2]...) 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(append([]m.ArtifactRef{}, files...), returns[2:]...) + 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) @@ -383,7 +317,7 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co if extraErr == nil { return fmt.Errorf("final candidate accepted an extra file") } - fmt.Println("V4 phase2 and final candidate passed: real contribution, custody, optional observers, second drand round, coordinator full replay, exact final inventory") + 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 { @@ -548,7 +482,7 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co if err = commit(); err != nil { return err } - checkpoint, err := headRefs() + checkpoint, err = headRefs() if err != nil { return err } @@ -574,7 +508,7 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co 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].ReturnReceipt.Record.Name, prepared.Bundle.Phase2.AcceptedHeads[0].AcceptedChainPrefix.Record.Name, prepared.Bundle.Phase2.RawBeaconResponses[0].Name} { + 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 diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go index 11634913..5ce8b909 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -99,7 +99,7 @@ func runCheckpointV4Review(root string, trust m.TrustPaths, d m.CeremonyDefiniti 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].OutboundReceipt.Record, bundleRecord.Phase1.RawBeaconResponses[0]} { + 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 { diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 6615d620..2e35615f 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -787,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 { @@ -881,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) { From 2861cf29e402fd38fbfe77d311c45d6fc2efef80 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:03:10 +0900 Subject: [PATCH 32/53] Check checkpoint reader close results --- internal/mpcceremony/checkpoint_v4_bundle.go | 2 +- internal/mpcceremony/checkpoint_v4_commitments.go | 2 +- .../mpcceremony/checkpoint_v4_enrollment_metadata.go | 2 +- internal/mpcceremony/checkpoint_v4_enrollments_test.go | 6 +++--- internal/mpcceremony/checkpoint_v4_files.go | 8 ++++---- internal/mpcceremony/checkpoint_v4_files_test.go | 4 ++-- internal/mpcceremony/checkpoint_v4_governance_test.go | 4 ++-- internal/mpcceremony/checkpoint_v4_release.go | 2 +- internal/mpcceremony/checkpoint_v4_review.go | 2 +- internal/mpcceremony/checkpoint_v4_turn.go | 10 +++++----- internal/mpcceremony/computation_output_v4.go | 2 +- internal/mpcceremony/contribution_allocation_v4.go | 2 +- internal/mpcceremony/contribution_inventory_v4.go | 2 +- internal/mpcceremony/decision_v3_verify.go | 4 ++-- internal/mpcceremony/decision_v3_verify_test.go | 2 +- internal/mpcceremony/release_v4.go | 2 +- 16 files changed, 28 insertions(+), 28 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4_bundle.go b/internal/mpcceremony/checkpoint_v4_bundle.go index c99b1321..7a874a93 100644 --- a/internal/mpcceremony/checkpoint_v4_bundle.go +++ b/internal/mpcceremony/checkpoint_v4_bundle.go @@ -43,7 +43,7 @@ func PrepareOperationalBundleV4(trust TrustPaths, artifactRoot string, head Sign if err != nil { return OperationalBundlePreparationV4{}, err } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() ancestry, err := loadCheckpointAncestryV4(reader, trusted.Definition, db, ds, head) if err != nil { return OperationalBundlePreparationV4{}, err diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go index 34d05155..b7c95f1a 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments.go +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -74,7 +74,7 @@ func InspectStoredCheckpointV4(trust TrustPaths, root string, head SignedArtifac if err != nil { return CheckpointV4{}, CheckpointCommitmentsV4{}, err } - defer c.reader.root.Close() + defer func() { _ = c.reader.root.Close() }() index, err := checkpointCommitmentsV4(c.ancestry) return c.ancestry.head, index, err } diff --git a/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go b/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go index ace93718..c485b70a 100644 --- a/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go +++ b/internal/mpcceremony/checkpoint_v4_enrollment_metadata.go @@ -28,7 +28,7 @@ func InspectCheckpointGuidanceV4(trust TrustPaths, artifactRoot string, head Sig if err != nil { return CheckpointV4{}, CheckpointCommitmentsV4{}, EnrollmentMetadataV4{}, err } - defer c.reader.root.Close() + defer func() { _ = c.reader.root.Close() }() index, err := checkpointCommitmentsV4(c.ancestry) if err != nil { return CheckpointV4{}, CheckpointCommitmentsV4{}, EnrollmentMetadataV4{}, err diff --git a/internal/mpcceremony/checkpoint_v4_enrollments_test.go b/internal/mpcceremony/checkpoint_v4_enrollments_test.go index af11ebdc..ad891068 100644 --- a/internal/mpcceremony/checkpoint_v4_enrollments_test.go +++ b/internal/mpcceremony/checkpoint_v4_enrollments_test.go @@ -40,7 +40,7 @@ func TestCheckpointV4EnrollmentDisclosureLimitMatchesBundle(t *testing.T) { t.Fatal(err) } _, err = readCheckpointEnrollmentV4(reader, d, db, refs) - reader.root.Close() + _ = reader.root.Close() if (err == nil) != (size <= maxEnrollmentDisclosureBytes) { t.Fatalf("size %d: %v", size, err) } @@ -79,7 +79,7 @@ func TestCheckpointV4ObserverAssignmentBound(t *testing.T) { t.Fatal(err) } _, err = readCheckpointEnrollmentV4(reader, d, db, refs) - reader.root.Close() + _ = reader.root.Close() if (err == nil) != (index <= MaxAuditors) { t.Fatalf("index %d: %v", index, err) } @@ -108,7 +108,7 @@ func TestCheckpointV4EnrollmentAuthenticatesDisclosureAndUniqueness(t *testing.T if err != nil { t.Fatal(err) } - defer reader.root.Close() + 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 { diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index b6bda1c5..c15a8554 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -243,7 +243,7 @@ func VerifyStoredCheckpointV4(trust TrustPaths, artifactRoot string, head Signed if err != nil { return CheckpointV4{}, err } - defer c.reader.root.Close() + defer func() { _ = c.reader.root.Close() }() return c.ancestry.head, nil } @@ -275,7 +275,7 @@ func openStoredCheckpointV4(trust TrustPaths, artifactRoot string, head SignedAr } ancestry, err := loadCheckpointAncestryV4(reader, trusted.Definition, db, ds, head) if err != nil { - reader.root.Close() + _ = reader.root.Close() return nil, err } return &storedCheckpointContextV4{reader: reader, trusted: trusted, definitionBytes: db, ancestry: ancestry}, nil @@ -319,7 +319,7 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { if err != nil { return nil, err } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() var previous *CheckpointV4 allocations := map[string]CheckpointTransitionV4{} enrollments := []SignedArtifactRefs{} @@ -490,7 +490,7 @@ func verifyRejectedInventoryV4(dir string, inventory CandidateInventory) error { if err != nil { return err } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() entries, err := reader.root.Open(".") if err != nil { return err diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 60a20cb7..ee447c00 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -213,7 +213,7 @@ func TestCheckpointV4ReaderStreamsAndConfinesFiles(t *testing.T) { if err != nil { t.Fatal(err) } - defer reader.root.Close() + 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) } @@ -318,7 +318,7 @@ func TestReceiptV4FindsCommittedHandoffAfterRetirement(t *testing.T) { if err != nil { t.Fatal(err) } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() previous := initial previous.Transition = CheckpointTransitionV4{Kind: CheckpointDeliveryRetired, Evidence: []ArtifactRef{}} tx := CheckpointTransitionV4{Kind: CheckpointPhase1ReceiptAccepted, Scope: &scope, Record: &r} diff --git a/internal/mpcceremony/checkpoint_v4_governance_test.go b/internal/mpcceremony/checkpoint_v4_governance_test.go index 220ee96b..552d4b86 100644 --- a/internal/mpcceremony/checkpoint_v4_governance_test.go +++ b/internal/mpcceremony/checkpoint_v4_governance_test.go @@ -66,7 +66,7 @@ func TestCheckpointV4GovernanceSemanticBinding(t *testing.T) { if err != nil { t.Fatal(err) } - defer reader.root.Close() + 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) @@ -145,7 +145,7 @@ func TestCheckpointV4RestartAuthenticatesExactNewDefinition(t *testing.T) { if err != nil { t.Fatal(err) } - defer reader.root.Close() + 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) diff --git a/internal/mpcceremony/checkpoint_v4_release.go b/internal/mpcceremony/checkpoint_v4_release.go index 69a6244b..5e73bc28 100644 --- a/internal/mpcceremony/checkpoint_v4_release.go +++ b/internal/mpcceremony/checkpoint_v4_release.go @@ -120,7 +120,7 @@ func verifyFinalReleasePackageV4(trust TrustPaths, root string, c CheckpointV4) if err != nil { return nil, empty, err } - defer reader.root.Close() + 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 { diff --git a/internal/mpcceremony/checkpoint_v4_review.go b/internal/mpcceremony/checkpoint_v4_review.go index 34129674..9e1eb04c 100644 --- a/internal/mpcceremony/checkpoint_v4_review.go +++ b/internal/mpcceremony/checkpoint_v4_review.go @@ -98,7 +98,7 @@ func verifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe if err != nil { return ReleaseReviewV4{}, err } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() reader.flatCandidate = flatCandidate a, err := loadCheckpointAncestryV4(reader, d, db, ds, head) if err != nil { diff --git a/internal/mpcceremony/checkpoint_v4_turn.go b/internal/mpcceremony/checkpoint_v4_turn.go index 5d3c22cd..7e2c7a93 100644 --- a/internal/mpcceremony/checkpoint_v4_turn.go +++ b/internal/mpcceremony/checkpoint_v4_turn.go @@ -43,7 +43,7 @@ func PrepareCandidateAllocationCheckpointV4(options CandidateAllocationCheckpoin return CandidateAllocationCheckpointV4{}, err } if stored.trusted.Definition.Schema != DefinitionSchemaV4 { - stored.reader.root.Close() + _ = stored.reader.root.Close() return CandidateAllocationCheckpointV4{}, errors.New("candidate allocation requires definition v4") } previous := stored.ancestry.head @@ -126,12 +126,12 @@ func VerifyAndAcceptAllocatedCandidateV4(options AcceptAllocatedCandidateV4Optio return AcceptedCandidateCheckpointV4{}, err } if stored.trusted.Definition.Schema != DefinitionSchemaV4 { - stored.reader.root.Close() + _ = 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() + _ = stored.reader.root.Close() return AcceptedCandidateCheckpointV4{}, errors.New("candidate attempt is not allocated by the authenticated checkpoint ancestry") } previous := stored.ancestry.head @@ -143,11 +143,11 @@ func VerifyAndAcceptAllocatedCandidateV4(options AcceptAllocatedCandidateV4Optio } } if !active { - stored.reader.root.Close() + _ = 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() + _ = stored.reader.root.Close() return AcceptedCandidateCheckpointV4{}, err } if err := stored.reader.root.Close(); err != nil { diff --git a/internal/mpcceremony/computation_output_v4.go b/internal/mpcceremony/computation_output_v4.go index 188e1b6c..35521af9 100644 --- a/internal/mpcceremony/computation_output_v4.go +++ b/internal/mpcceremony/computation_output_v4.go @@ -39,7 +39,7 @@ func InspectComputationOutputV4(trust TrustPaths, predecessor PhaseTranscriptPat if err != nil { return zero, err } - defer r.root.Close() + defer func() { _ = r.root.Close() }() result, _, err := inspectComputationOutputV4(r, d, chain, expected) if err != nil { return zero, err diff --git a/internal/mpcceremony/contribution_allocation_v4.go b/internal/mpcceremony/contribution_allocation_v4.go index a7ee10cc..20556f6e 100644 --- a/internal/mpcceremony/contribution_allocation_v4.go +++ b/internal/mpcceremony/contribution_allocation_v4.go @@ -33,7 +33,7 @@ func CreateAllocatedContributionCandidateV4(options AllocatedContributionFilesV4 if err != nil { return ContributionFilesResult{}, err } - defer stored.reader.root.Close() + defer func() { _ = stored.reader.root.Close() }() if stored.trusted.Definition.Schema != DefinitionSchemaV4 { return ContributionFilesResult{}, errors.New("allocated contribution requires definition v4") } diff --git a/internal/mpcceremony/contribution_inventory_v4.go b/internal/mpcceremony/contribution_inventory_v4.go index 32ecdf16..0369d555 100644 --- a/internal/mpcceremony/contribution_inventory_v4.go +++ b/internal/mpcceremony/contribution_inventory_v4.go @@ -50,7 +50,7 @@ func InspectContributionInventoryV4(trust TrustPaths, predecessor PhaseTranscrip if err != nil { return ContributionInventoryInspectionV4{}, err } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() result, err := inspectContributionInventoryV4(reader, d, chain, expected) if err != nil { return ContributionInventoryInspectionV4{}, err diff --git a/internal/mpcceremony/decision_v3_verify.go b/internal/mpcceremony/decision_v3_verify.go index c9c280ac..c6ca0373 100644 --- a/internal/mpcceremony/decision_v3_verify.go +++ b/internal/mpcceremony/decision_v3_verify.go @@ -103,7 +103,7 @@ func VerifyProductionDecisionEvidenceV4(o VerifyProductionDecisionEvidenceV4Opti if err != nil { return empty, err } - defer packageReader.root.Close() + 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) @@ -124,7 +124,7 @@ func VerifyProductionDecisionEvidenceV4(o VerifyProductionDecisionEvidenceV4Opti if err != nil { return empty, err } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() refs, err := verifyDecisionExternalEvidenceV3(reader, decision) if err != nil { return empty, err diff --git a/internal/mpcceremony/decision_v3_verify_test.go b/internal/mpcceremony/decision_v3_verify_test.go index 8cccd463..b6141252 100644 --- a/internal/mpcceremony/decision_v3_verify_test.go +++ b/internal/mpcceremony/decision_v3_verify_test.go @@ -235,7 +235,7 @@ func TestDecisionV3ExternalEvidenceBytesAndSignoff(t *testing.T) { if err != nil { t.Fatal(err) } - defer reader.root.Close() + defer func() { _ = reader.root.Close() }() if _, err := verifyDecisionExternalEvidenceV3(reader, x); err != nil { t.Fatal(err) } diff --git a/internal/mpcceremony/release_v4.go b/internal/mpcceremony/release_v4.go index 53185a63..8ece9660 100644 --- a/internal/mpcceremony/release_v4.go +++ b/internal/mpcceremony/release_v4.go @@ -198,7 +198,7 @@ func readReleaseReviewHeadV4(trust TrustPaths, root string, refs SignedArtifactR if err != nil { return CheckpointV4{}, err } - defer r.root.Close() + defer func() { _ = r.root.Close() }() rb, rs, err := r.pair(refs) if err != nil { return CheckpointV4{}, err From 06d6b0dbc871c8e0bc9a53a372f155828e87b57e Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 13:44:07 +0900 Subject: [PATCH 33/53] Bind upload grants to allocation checkpoints --- internal/mpcceremony/checkpoint_v4_commitments.go | 14 +++++++++----- .../mpcceremony/checkpoint_v4_commitments_test.go | 7 +++++-- internal/mpcceremony/checkpoint_v4_files.go | 2 +- 3 files changed, 15 insertions(+), 8 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go index b7c95f1a..ea543db8 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments.go +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -15,9 +15,10 @@ type CheckpointCommitmentsV4 struct { } type CandidateAllocationV4 struct { - CheckpointSequence uint64 `json:"checkpoint_sequence"` - AttemptID string `json:"attempt_id"` - AllocatedAt string `json:"allocated_at"` + CheckpointSequence uint64 `json:"checkpoint_sequence"` + Checkpoint SignedArtifactRefs `json:"checkpoint"` + AttemptID string `json:"attempt_id"` + AllocatedAt string `json:"allocated_at"` } type AcceptedChainCommitmentV4 struct { @@ -32,7 +33,10 @@ type TurnCommitmentV4 struct { AcceptedChain *AcceptedChainCommitmentV4 `json:"accepted_chain,omitempty"` } -func collectTurnCommitmentV4(turns map[ContributionScope]*TurnCommitmentV4, c CheckpointV4) error { +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: @@ -53,7 +57,7 @@ func collectTurnCommitmentV4(turns map[ContributionScope]*TurnCommitmentV4, c Ch if len(turn.Allocations) >= MaxDeliveryAttemptsPerSubmissionV2 { return errors.New("candidate allocation index exceeds attempt limit") } - turn.Allocations = append(turn.Allocations, CandidateAllocationV4{CheckpointSequence: c.Sequence, AttemptID: t.AttemptID, AllocatedAt: t.AllocatedAt}) + 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") diff --git a/internal/mpcceremony/checkpoint_v4_commitments_test.go b/internal/mpcceremony/checkpoint_v4_commitments_test.go index 1f3cc9fa..980087d8 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments_test.go +++ b/internal/mpcceremony/checkpoint_v4_commitments_test.go @@ -51,6 +51,9 @@ func TestCheckpointCommitmentsSurviveUnrelatedEdges(t *testing.T) { 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") } @@ -94,11 +97,11 @@ func TestTurnCommitmentBoundsV4(t *testing.T) { for n := 0; n < MaxDeliveryAttemptsPerSubmissionV2; n++ { tx.Sequence = uint64(MaxDeliveryAttemptsPerSubmissionV2 - n) tx.Transition.AttemptID = fmt.Sprintf("%032x", n+1) - if err := collectTurnCommitmentV4(turns, tx); err != nil { + if err := collectTurnCommitmentV4(turns, tx, checkpointSigned(fmt.Sprintf("checkpoints/%04d", n))); err != nil { t.Fatal(err) } } - if err := collectTurnCommitmentV4(turns, tx); err == nil { + if err := collectTurnCommitmentV4(turns, tx, checkpointSigned("checkpoints/overflow")); err == nil { t.Fatal("allocation bound not enforced") } } diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index c15a8554..740beaaf 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -185,7 +185,7 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, } } result.count++ - if err := collectTurnCommitmentV4(result.turnCommitments, current); err != nil { + if err := collectTurnCommitmentV4(result.turnCommitments, current, refs); err != nil { return checkpointAncestryV4{}, err } result.checkpoints = append(result.checkpoints, refs) From 7f71a990a430d29eaeacf687cf382474676ccc01 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:16:22 +0900 Subject: [PATCH 34/53] Derive initial storage checkpoint in proof tool --- cmd/mpc-ceremony/checkpoint_command.go | 2 +- cmd/mpc-ceremony/checkpoint_v4.go | 63 ++++++++++++---- cmd/mpc-ceremony/checkpoint_v4_parse_test.go | 35 +++++++++ cmd/mpc-ceremony/executor.go | 2 +- cmd/mpc-ceremony/integration_test.go | 2 + cmd/mpc-ceremony/types.go | 1 + cmd/mpc-ceremony/usage.go | 12 +++- .../mpcceremony/checkpoint_v4_initialize.go | 71 +++++++++++++++++++ .../testdata/workflowhelper/checkpoint_v4.go | 9 +-- 9 files changed, 175 insertions(+), 22 deletions(-) create mode 100644 cmd/mpc-ceremony/checkpoint_v4_parse_test.go create mode 100644 internal/mpcceremony/checkpoint_v4_initialize.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 7ee364ae..d98f87b2 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -49,7 +49,7 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { options, err := parseEvidenceV4(CommandCheckpointVerifyReleaseV4, args[1:]) invocation.Command, invocation.Options = CommandCheckpointVerifyReleaseV4, options return invocation, wrapCommandError(err, "checkpoint", args[0]) - case "prepare-v4", "sign-v4", "allocate-v4", "accept-candidate-v4", "verify-stored-v4", "inspect-signed-v4", "inspect-enrollments-v4": + case "prepare-v4", "sign-v4", "initialize-v4", "allocate-v4", "accept-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]) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index 014fbd22..83c26211 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -59,20 +59,25 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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 == "allocate-v4" || action == "accept-candidate-v4" { - 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 == "allocate-v4" || action == "accept-candidate-v4" { - fs.StringVar(&o.AttemptID, "attempt-id", "", "fresh 32-character hexadecimal delivery attempt ID") + if checkpointReadOnlyActionV4(action) || action == "initialize-v4" || action == "allocate-v4" || action == "accept-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 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") + 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 == "allocate-v4" || action == "accept-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") + } } } else { fs.StringVar(&o.ProposalPath, "proposal", "", "exact canonical V4 checkpoint proposal") @@ -91,6 +96,9 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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 == "allocate-v4" || action == "accept-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 @@ -147,6 +155,35 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, if err := m.VerifyRunningSoftwareForMode(d.Software, d.Mode); err != nil { return CommandResult{}, err } + if command == CommandCheckpointInitializeV4 { + 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") + } + 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 + } + 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 == CommandCheckpointAllocateV4 || command == CommandCheckpointAcceptCandidateV4 { _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) if err != 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/executor.go b/cmd/mpc-ceremony/executor.go index 2c08b608..c02896f9 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -125,7 +125,7 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeInspectSubmissionAcknowledgement(invocation.Options.(InspectSubmissionAcknowledgementOptions)) case CommandCheckpointPrepare: return executeCheckpointPrepare(invocation.Options.(CheckpointPrepareOptions)) - case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointAllocateV4, CommandCheckpointAcceptCandidateV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: + case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointInitializeV4, CommandCheckpointAllocateV4, CommandCheckpointAcceptCandidateV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: return executeCheckpointV4(invocation.Command, invocation.Options.(CheckpointOptionsV4)) case CommandCheckpointSign: return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 6e09271a..24f6e7fa 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -49,6 +49,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"decision", "verify"}, {"checkpoint", "prepare-v4"}, {"checkpoint", "sign-v4"}, + {"checkpoint", "initialize-v4"}, {"checkpoint", "allocate-v4"}, {"checkpoint", "accept-candidate-v4"}, {"checkpoint", "verify-stored-v4"}, @@ -266,6 +267,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandCheckpointVerifyStored, Options: CheckpointVerifyStoredOptions{}}, {Command: CommandCheckpointPrepareV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointSignV4, Options: CheckpointOptionsV4{}}, + {Command: CommandCheckpointInitializeV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointVerifyStoredV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointInspectSignedV4, Options: CheckpointOptionsV4{}}, {Command: CommandCheckpointInspectEnrollmentsV4, Options: CheckpointOptionsV4{}}, diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 9ef23d95..78cd2570 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -70,6 +70,7 @@ const ( CommandCheckpointVerifyStored Command = "checkpoint verify-stored" CommandCheckpointPrepareV4 Command = "checkpoint prepare-v4" CommandCheckpointSignV4 Command = "checkpoint sign-v4" + CommandCheckpointInitializeV4 Command = "checkpoint initialize-v4" CommandCheckpointAllocateV4 Command = "checkpoint allocate-v4" CommandCheckpointAcceptCandidateV4 Command = "checkpoint accept-candidate-v4" CommandCheckpointVerifyStoredV4 Command = "checkpoint verify-stored-v4" diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index f5b005d3..d96981e5 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -309,6 +309,16 @@ 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. +`, + "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. `, "checkpoint accept-candidate-v4": `Usage: mpc-ceremony checkpoint accept-candidate-v4 --ceremony FILE --ceremony-signature FILE \ @@ -365,7 +375,7 @@ 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 [flags] mpc-ceremony checkpoint inspect-signed-v4 [flags] mpc-ceremony checkpoint inspect-enrollments-v4 [flags] diff --git a/internal/mpcceremony/checkpoint_v4_initialize.go b/internal/mpcceremony/checkpoint_v4_initialize.go new file mode 100644 index 00000000..efd68d6f --- /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, 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/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 6dd2de7c..0c5a12f9 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -66,14 +66,11 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if err != nil { return err } - definition, err := pair("ceremony") + initial, err := m.PrepareInitialCheckpointV4(m.InitialCheckpointV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: root}) if err != nil { - return err + return fmt.Errorf("derive initial checkpoint: %w", err) } - c := 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: sorted([]m.ArtifactRef{definition.Record, definition.Signature, chainRefs.Record, chainRefs.Signature, payload}), Deliveries: []m.DeliverySlotV2{}} + c := initial.Checkpoint var committed m.SignedArtifactRefs commit := func() error { if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: c, Circuit: circuit}); err != nil { From ee45b51dc364429667aaf7bd77230377cbe43308 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 14:27:20 +0900 Subject: [PATCH 35/53] Derive signed V4 lifecycle checkpoints --- cmd/mpc-ceremony/checkpoint_command.go | 2 +- cmd/mpc-ceremony/checkpoint_v4.go | 95 +++++++++++++++- cmd/mpc-ceremony/checkpoint_v4_test.go | 42 +++++++ cmd/mpc-ceremony/executor.go | 2 +- cmd/mpc-ceremony/integration_test.go | 5 + cmd/mpc-ceremony/types.go | 1 + cmd/mpc-ceremony/usage.go | 20 +++- internal/mpcceremony/checkpoint_v4_record.go | 113 +++++++++++++++++++ 8 files changed, 271 insertions(+), 9 deletions(-) create mode 100644 internal/mpcceremony/checkpoint_v4_record.go diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index d98f87b2..37880ecd 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -49,7 +49,7 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { 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", "allocate-v4", "accept-candidate-v4", "verify-stored-v4", "inspect-signed-v4", "inspect-enrollments-v4": + case "prepare-v4", "sign-v4", "initialize-v4", "record-v4", "allocate-v4", "accept-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]) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index 83c26211..76f78868 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -16,6 +16,8 @@ type CheckpointOptionsV4 struct { CheckpointPath, CheckpointSignaturePath string CoordinatorSigningKey, OutPath, OutDir string AttemptID, AllocatedAt, AcceptedAt, CandidateDir string + TransitionKind, RecordPath, RecordSignaturePath string + EvidencePaths []string } type CheckpointInspectionV4 struct { @@ -59,13 +61,21 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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 == "allocate-v4" || action == "accept-candidate-v4" { + if checkpointReadOnlyActionV4(action) || action == "initialize-v4" || action == "record-v4" || action == "allocate-v4" || action == "accept-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" { fs.StringVar(&o.AttemptID, "attempt-id", "", "fresh 32-character hexadecimal delivery attempt ID") fs.StringVar(&o.CoordinatorSigningKey, "coordinator-signing-key", "", "existing coordinator private key") @@ -99,6 +109,9 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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" { 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 @@ -155,7 +168,20 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, 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 @@ -163,26 +189,69 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, if !bytes.Equal(public, trusted.CoordinatorPublicKey) { return CommandResult{}, errors.New("checkpoint signing key is not the authenticated coordinator key") } - circuit, err := loadCheckpointCircuitV4(o.ArtifactRoot, d) + signature, err := m.SignExact(prepared.Canonical, d.Coordinator.KeyID, private) if err != nil { return CommandResult{}, err } - prepared, err := m.PrepareInitialCheckpointV4(m.InitialCheckpointV4Options{Trust: trust, Circuit: circuit, ArtifactRoot: o.ArtifactRoot}) + signatureBytes, err := m.MarshalCanonical(signature) if err != nil { return CommandResult{}, err } - signature, err := m.SignExact(prepared.Canonical, d.Coordinator.KeyID, private) + 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 } - signatureBytes, err := m.MarshalCanonical(signature) + 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, 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 + 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 { _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) @@ -406,3 +475,17 @@ func validateCheckpointPathsV4(o CheckpointOptionsV4) error { } 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") + } + } + return nil +} diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 0c30ec47..de66f187 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -151,6 +151,16 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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) @@ -183,6 +193,38 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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") + 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) + } // Sign again only after rereading every required byte, not a saved success marker. genesis := filepath.Join(artifactRoot, payload.Name) original := mustReadTestFile(t, genesis) diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index c02896f9..fe3a0831 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -125,7 +125,7 @@ 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, CommandCheckpointAllocateV4, CommandCheckpointAcceptCandidateV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: + case CommandCheckpointPrepareV4, CommandCheckpointSignV4, CommandCheckpointInitializeV4, CommandCheckpointRecordV4, CommandCheckpointAllocateV4, CommandCheckpointAcceptCandidateV4, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: return executeCheckpointV4(invocation.Command, invocation.Options.(CheckpointOptionsV4)) case CommandCheckpointSign: return executeCheckpointSign(invocation.Options.(CheckpointSignOptions)) diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 24f6e7fa..823b6cdd 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -50,6 +50,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"checkpoint", "prepare-v4"}, {"checkpoint", "sign-v4"}, {"checkpoint", "initialize-v4"}, + {"checkpoint", "record-v4"}, {"checkpoint", "allocate-v4"}, {"checkpoint", "accept-candidate-v4"}, {"checkpoint", "verify-stored-v4"}, @@ -201,7 +202,10 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--checkpoint", "--checkpoint-signature", "--record", + "--record-signature", "--record-type", + "--evidence", + "--transition", "--canonical", "--signature", "--signer-public-key-file", @@ -268,6 +272,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {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{}}, diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 78cd2570..0fd96300 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -71,6 +71,7 @@ const ( 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" CommandCheckpointVerifyStoredV4 Command = "checkpoint verify-stored-v4" diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index d96981e5..b6c4d9cd 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -309,6 +309,7 @@ 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 \ @@ -319,6 +320,22 @@ 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 \ @@ -332,6 +349,7 @@ 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 inspect-enrollments-v4": `Usage: mpc-ceremony checkpoint inspect-enrollments-v4 --ceremony FILE --ceremony-signature FILE \ @@ -375,7 +393,7 @@ 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 [flags] mpc-ceremony checkpoint inspect-signed-v4 [flags] mpc-ceremony checkpoint inspect-enrollments-v4 [flags] diff --git a/internal/mpcceremony/checkpoint_v4_record.go b/internal/mpcceremony/checkpoint_v4_record.go new file mode 100644 index 00000000..c096b82f --- /dev/null +++ b/internal/mpcceremony/checkpoint_v4_record.go @@ -0,0 +1,113 @@ +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, + CheckpointBeaconEvidenceRecorded, CheckpointAuditRecorded, CheckpointIncidentRecorded, + CheckpointPhase1Closed, CheckpointPhase1BeaconRecorded, CheckpointPhase1Sealed, + CheckpointPhase2Initialized, CheckpointPhase2Closed, CheckpointPhase2BeaconRecorded, + CheckpointFinalCandidateRecorded, 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 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, + }) + if err != nil { + return RecordedCheckpointV4{}, err + } + return RecordedCheckpointV4{Checkpoint: next, Canonical: canonical}, nil +} From f6ffbd1eb14fb6ea99293919181eaf5734fcc0cf Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 15:57:41 +0900 Subject: [PATCH 36/53] Record signed V4 release review state --- cmd/mpc-ceremony/checkpoint_v4.go | 2 +- cmd/mpc-ceremony/checkpoint_v4_test.go | 2 +- internal/mpcceremony/checkpoint_v4.go | 23 ++++++++++++-- internal/mpcceremony/checkpoint_v4_files.go | 30 +++++++++++++++++++ internal/mpcceremony/checkpoint_v4_record.go | 4 ++- .../mpcceremony/checkpoint_v4_release_test.go | 6 ++-- internal/mpcceremony/checkpoint_v4_review.go | 7 +++-- internal/mpcceremony/checkpoint_v4_test.go | 17 +++++++++-- .../workflowhelper/checkpoint_v4_final.go | 9 ++++++ .../workflowhelper/checkpoint_v4_review.go | 6 ++-- 10 files changed, 90 insertions(+), 16 deletions(-) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index 76f78868..a9f04f34 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -146,7 +146,7 @@ func checkpointNeedsCircuitV4(kind m.CheckpointTransitionKind) (bool, error) { case m.CheckpointPhase1CandidateAllocated, m.CheckpointPhase2CandidateAllocated, m.CheckpointDeliveryRetired, m.CheckpointDeliveryReallocated, m.CheckpointContributionRejected, m.CheckpointPhase1Closed, m.CheckpointPhase2Closed, m.CheckpointPhase1BeaconRecorded, m.CheckpointPhase2BeaconRecorded, - m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, + m.CheckpointReleaseReviewRecorded, m.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, m.CheckpointWitnessRecorded, m.CheckpointBeaconEvidenceRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted: return false, nil diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index de66f187..44472e34 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -21,7 +21,7 @@ func TestCheckpointV4CircuitClassification(t *testing.T) { 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.CheckpointFinalReleaseRecorded, m.CheckpointEnrollmentRecorded, m.CheckpointMirrorRecorded, m.CheckpointWitnessRecorded, m.CheckpointBeaconEvidenceRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted}}, + {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.CheckpointBeaconEvidenceRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted}}, } { for _, kind := range tc.kinds { if got, err := checkpointNeedsCircuitV4(kind); err != nil || got != tc.math { diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 15d86939..0144622e 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -21,6 +21,7 @@ const ( CheckpointWitnessRecorded CheckpointTransitionKind = "witness-recorded" CheckpointBeaconEvidenceRecorded CheckpointTransitionKind = "beacon-evidence-recorded" CheckpointAuditRecorded CheckpointTransitionKind = "audit-recorded" + CheckpointReleaseReviewRecorded CheckpointTransitionKind = "release-review-recorded" CheckpointIncidentRecorded CheckpointTransitionKind = "incident-recorded" CheckpointAborted CheckpointTransitionKind = "ceremony-aborted" CheckpointRestarted CheckpointTransitionKind = "ceremony-restarted" @@ -37,6 +38,7 @@ type CheckpointProgressV4 struct { 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"` } @@ -176,7 +178,7 @@ func (c CheckpointV4) Validate() error { } 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.FinalRelease} + 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 { @@ -359,6 +361,10 @@ func (t CheckpointTransitionV4) Validate() error { 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") } @@ -492,6 +498,9 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { 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} @@ -514,6 +523,9 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { 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") } @@ -578,14 +590,19 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { 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.FinalRelease != nil { - return errors.New("final release requires a frozen final candidate") + 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: diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 740beaaf..24e4daa8 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -376,6 +376,36 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { } 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 { diff --git a/internal/mpcceremony/checkpoint_v4_record.go b/internal/mpcceremony/checkpoint_v4_record.go index c096b82f..555d3ae6 100644 --- a/internal/mpcceremony/checkpoint_v4_record.go +++ b/internal/mpcceremony/checkpoint_v4_record.go @@ -29,7 +29,7 @@ func recordableCheckpointKindV4(kind CheckpointTransitionKind) bool { CheckpointBeaconEvidenceRecorded, CheckpointAuditRecorded, CheckpointIncidentRecorded, CheckpointPhase1Closed, CheckpointPhase1BeaconRecorded, CheckpointPhase1Sealed, CheckpointPhase2Initialized, CheckpointPhase2Closed, CheckpointPhase2BeaconRecorded, - CheckpointFinalCandidateRecorded, CheckpointFinalReleaseRecorded, CheckpointAborted: + CheckpointFinalCandidateRecorded, CheckpointReleaseReviewRecorded, CheckpointFinalReleaseRecorded, CheckpointAborted: return true default: return false @@ -97,6 +97,8 @@ func PrepareRecordedCheckpointV4(options RecordedCheckpointV4Options) (RecordedC } 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: diff --git a/internal/mpcceremony/checkpoint_v4_release_test.go b/internal/mpcceremony/checkpoint_v4_release_test.go index 749eda4d..b0028675 100644 --- a/internal/mpcceremony/checkpoint_v4_release_test.go +++ b/internal/mpcceremony/checkpoint_v4_release_test.go @@ -119,11 +119,11 @@ func TestFinalReleaseV4SequenceCapacity(t *testing.T) { // Synthetic capacity boundary; the real fixture separately checks semantic // authoring. Extra references here stand for previously accepted evidence. func TestFinalReleaseV4InventoryCapacity(t *testing.T) { - d, c, _, _ := checkpointFixtureV4(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} { + 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) @@ -132,7 +132,7 @@ func TestFinalReleaseV4InventoryCapacity(t *testing.T) { 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: CheckpointFinalCandidateRecorded, Record: c.Progress.FinalCandidate, Evidence: []ArtifactRef{payload}, ReplayVerification: &CheckpointReplayVerificationV4{Method: CoordinatorReplayReleaseV1, ToolBinary: d.Software.ToolBinary}} + 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")) } diff --git a/internal/mpcceremony/checkpoint_v4_review.go b/internal/mpcceremony/checkpoint_v4_review.go index 9e1eb04c..3b4ff7c6 100644 --- a/internal/mpcceremony/checkpoint_v4_review.go +++ b/internal/mpcceremony/checkpoint_v4_review.go @@ -104,8 +104,11 @@ func verifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe if err != nil { return ReleaseReviewV4{}, err } - if a.head.Progress.Terminal != nil || a.head.Progress.FinalRelease != nil || a.finalCandidateCheckpoint == nil { - return ReleaseReviewV4{}, errors.New("review requires an unreleased, unterminated final candidate") + 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 { diff --git a/internal/mpcceremony/checkpoint_v4_test.go b/internal/mpcceremony/checkpoint_v4_test.go index f2a343cc..9f2bc407 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -124,11 +124,11 @@ 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, CheckpointFinalReleaseRecorded} + 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 { + if kind != CheckpointPhase1Closed && kind != CheckpointPhase2Closed && kind != CheckpointReleaseReviewRecorded { evidence = append(evidence, checkpointArtifact("lifecycle/"+string(kind)+".bin", "payload")) } if kind == CheckpointFinalReleaseRecorded { @@ -167,6 +167,19 @@ func TestCheckpointV4FullStructuralLifecycle(t *testing.T) { 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 } diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go index 4af08257..65c5c4ca 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -549,6 +549,15 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co 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 diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go index 5ce8b909..514a99b9 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_review.go @@ -226,9 +226,9 @@ func runCheckpointV4Review(root string, trust m.TrustPaths, d m.CeremonyDefiniti if err != nil { return err } - if result, err := m.VerifyReleaseReviewV4(trust, root, newHead, bundle, at); err == nil || !strings.Contains(err.Error(), "signed bundle does not match the exact review checkpoint") || result.CeremonyID != "" { - return fmt.Errorf("stale bundle review: %v", 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, stale bundle and changed files rejected") + fmt.Println("V4 final review passed: no contribution replay input, deterministic exact binding, changed files and post-review evidence rejected") return nil } From fc92ccb644cba30ce4ad75b5cffe9bdf63a454b2 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:03:58 +0900 Subject: [PATCH 37/53] Allow cross-platform V4 publication verification --- cmd/mpc-ceremony/checkpoint_v4.go | 6 ++++- cmd/mpc-ceremony/checkpoint_v4_test.go | 3 +++ cmd/mpc-ceremony/evidence_v4_test.go | 27 +++++++++++++++++++ internal/mpcceremony/checkpoint_v4_files.go | 5 ++++ internal/mpcceremony/checkpoint_v4_final.go | 15 +++++++---- internal/mpcceremony/checkpoint_v4_record.go | 1 + .../testdata/workflowhelper/checkpoint_v4.go | 2 +- 7 files changed, 52 insertions(+), 7 deletions(-) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index a9f04f34..1e432a4f 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -396,7 +396,11 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, return CommandResult{}, err } } - checked, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: o.ArtifactRoot, Proposal: proposal, Circuit: circuit, RejectedCandidateDir: o.RejectedCandidateDir}) + 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 } diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 44472e34..cdceea84 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -194,6 +194,9 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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 diff --git a/cmd/mpc-ceremony/evidence_v4_test.go b/cmd/mpc-ceremony/evidence_v4_test.go index 6475d0a5..70173ced 100644 --- a/cmd/mpc-ceremony/evidence_v4_test.go +++ b/cmd/mpc-ceremony/evidence_v4_test.go @@ -324,6 +324,33 @@ func TestEvidenceV4CommandsOnRealArtifacts(t *testing.T) { } 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") + } + signingArgs := append([]string{}, publicationArgs...) + signingArgs[3] = "sign-v4" + signingArgs = append(signingArgs, "--coordinator-signing-key", filepath.Join(runRoot, "identity-keys/coordinator.ed25519.private.hex")) + signingArgs[len(signingArgs)-3] = filepath.Join(t.TempDir(), "checkpoint.sig") + if b, err := exec.Command(cli, signingArgs...).CombinedOutput(); 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) + } metadataResult, err := execute(CommandCheckpointInspectEnrollmentsV4, o) if err != nil { t.Fatal(err) diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 24e4daa8..d8f88429 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -289,6 +289,11 @@ type CheckpointPreparationV4 struct { 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 diff --git a/internal/mpcceremony/checkpoint_v4_final.go b/internal/mpcceremony/checkpoint_v4_final.go index 145b3c9b..c4f1d77b 100644 --- a/internal/mpcceremony/checkpoint_v4_final.go +++ b/internal/mpcceremony/checkpoint_v4_final.go @@ -13,12 +13,17 @@ func verifyFinalCandidateV4(options CheckpointPreparationV4, trusted *TrustedCer 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") } - running, err := RunningSoftwareBindingForMode(trusted.Definition.Software.ProofToolVersion, trusted.Definition.Mode) - if err != nil { - return err + if t.ReplayVerification == nil { + return errors.New("final candidate requires the coordinator replay claim") } - if t.ReplayVerification == nil || t.ReplayVerification.ToolBinary != running.ToolBinary { - return errors.New("final candidate replay claim must identify the actual approved executable") + 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 { diff --git a/internal/mpcceremony/checkpoint_v4_record.go b/internal/mpcceremony/checkpoint_v4_record.go index 555d3ae6..e303ad4b 100644 --- a/internal/mpcceremony/checkpoint_v4_record.go +++ b/internal/mpcceremony/checkpoint_v4_record.go @@ -107,6 +107,7 @@ func PrepareRecordedCheckpointV4(options RecordedCheckpointV4Options) (RecordedC canonical, err := PrepareCheckpointV4(CheckpointPreparationV4{ Trust: options.Trust, ArtifactRoot: options.ArtifactRoot, Proposal: next, Circuit: options.Circuit, + RequireCurrentReplayExecutable: true, }) if err != nil { return RecordedCheckpointV4{}, err diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 0c5a12f9..1437c9ed 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -73,7 +73,7 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com c := initial.Checkpoint var committed m.SignedArtifactRefs commit := func() error { - if _, err := m.PrepareCheckpointV4(m.CheckpointPreparationV4{Trust: trust, ArtifactRoot: root, Proposal: c, Circuit: circuit}); err != nil { + 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 From f2481addbf3269cfc1d80c7059ee0f6d65815204 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:40:50 +0900 Subject: [PATCH 38/53] Fix V4 lint findings --- cmd/mpc-ceremony/evidence_v4.go | 2 +- internal/mpcceremony/chain.go | 9 ++-- internal/mpcceremony/checkpoint_v4_files.go | 49 -------------------- internal/mpcceremony/checkpoint_v4_review.go | 2 +- internal/mpcceremony/delivery_scope.go | 9 ++-- 5 files changed, 14 insertions(+), 57 deletions(-) diff --git a/cmd/mpc-ceremony/evidence_v4.go b/cmd/mpc-ceremony/evidence_v4.go index a44be1a2..953591a1 100644 --- a/cmd/mpc-ceremony/evidence_v4.go +++ b/cmd/mpc-ceremony/evidence_v4.go @@ -124,7 +124,7 @@ func validateBundleReviewV4(o EvidenceOptionsV4) error { 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') { + if (c < '0' || c > '9') && (c < 'a' || c > 'f') { return errors.New("reviewed SHA-256 must be 64 lowercase hexadecimal characters") } } diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index 3371b735..6df106e2 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -1172,11 +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" - } else if record.Schema == FinalTranscriptSchemaV3 { + case FinalTranscriptSchemaV3: domain = "proof-tool/mpc-ceremony/final-transcript/v3" + default: + domain = "proof-tool/mpc-ceremony/final-transcript/v2" } return canonicalHash(domain, record) } diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index d8f88429..45fc1e07 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -690,55 +690,6 @@ func verifyCandidateChainInventoryV4(last ChainRecord, scope ContributionScope, return errors.New("candidate verification artifact is missing") } -func verifyReturnHandoffV4(reader *checkpointReaderV4, d CeremonyDefinition, scope ContributionScope, inventory CandidateInventory) error { - if err := inventory.Validate(); err != nil { - return err - } - if len(inventory.Files) != 7 || inventory.Scope != scope { - return errors.New("return handoff requires the complete seven-file inventory for this scope") - } - base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) - refs := SignedArtifactRefs{Record: ArtifactRef{Name: base + inventory.Files[5].Name, Digest: inventory.Files[5].Digest}, Signature: ArtifactRef{Name: base + inventory.Files[6].Name, Digest: inventory.Files[6].Digest}} - record, signature, err := reader.pair(refs) - if err != nil { - return err - } - return verifyReturnHandoffBytesV4(d, scope, inventory, record, signature) -} - -func verifyReturnHandoffBytesV4(d CeremonyDefinition, scope ContributionScope, inventory CandidateInventory, record, signature []byte) error { - if err := inventory.Validate(); err != nil { - return err - } - if len(inventory.Files) != 7 || inventory.Scope != scope || NewDigest(record) != inventory.Files[5].Digest || NewDigest(signature) != inventory.Files[6].Digest { - return errors.New("return handoff differs from the complete candidate inventory") - } - base := fmt.Sprintf("%s/contributions/%04d/", scope.Phase, scope.Index) - participant, ok := d.ParticipantByID(scope.ParticipantID) - if !ok { - return errors.New("return sender is not in roster") - } - key, err := identityPublicKey(participant.Identity) - if err != nil { - return err - } - var handoff TransferHandoff - if err := VerifySignedRecord(record, signature, &handoff, participant.Identity.KeyID, key); err != nil { - return err - } - if err := verifyTransferSource(d, handoff.Source); err != nil { - return err - } - expected := append([]ArtifactRef{}, inventory.Files[:5]...) - for i := range expected { - expected[i].Name = base + expected[i].Name - } - if handoff.CeremonyID != d.CeremonyID || handoff.Phase != scope.Phase || handoff.Index != scope.Index || handoff.PredecessorHeadID != scope.ParentHeadID || handoff.SenderID != scope.ParticipantID || handoff.SenderKeyID != participant.Identity.KeyID || handoff.RecipientID != d.Coordinator.ID || handoff.RecipientKeyID != d.Coordinator.KeyID || !slices.Equal(handoff.Files, expected) { - return errors.New("return handoff does not bind this participant and complete candidate") - } - return nil -} - func verifyV4ChainProjection(chain Chain, refs SignedArtifactRefs, state CheckpointPhaseState) error { head, err := chain.HeadPayload() if err != nil { diff --git a/internal/mpcceremony/checkpoint_v4_review.go b/internal/mpcceremony/checkpoint_v4_review.go index 3b4ff7c6..5430aed5 100644 --- a/internal/mpcceremony/checkpoint_v4_review.go +++ b/internal/mpcceremony/checkpoint_v4_review.go @@ -125,7 +125,7 @@ func verifyReleaseReviewV4(trust TrustPaths, artifactRoot string, head, bundleRe return ReleaseReviewV4{}, errors.New("review requires the canonical final candidate pair") } for _, ref := range append(signedArtifacts(final.Transition.Record), final.Transition.Evidence...) { - limit := int64(MaxArtifactSize) + limit := MaxArtifactSize if strings.HasSuffix(ref.Name, ".json") || strings.HasSuffix(ref.Name, ".sig") || strings.HasSuffix(ref.Name, ".txt") { limit = maxSignedRecordBytes } diff --git a/internal/mpcceremony/delivery_scope.go b/internal/mpcceremony/delivery_scope.go index c5b7dfc7..4752f538 100644 --- a/internal/mpcceremony/delivery_scope.go +++ b/internal/mpcceremony/delivery_scope.go @@ -85,11 +85,14 @@ func (c CandidateInventory) Validate() error { if ref.Name != expected[i] { return fmt.Errorf("candidate file %d must be %s", i, expected[i]) } - limit := int64(maxSignedRecordBytes) - if ref.Name == "contribution.bin" { + var limit int64 + switch ref.Name { + case "contribution.bin": limit = MaxArtifactSize - } else if ref.Name == "attestation.sig" || ref.Name == "erasure.sig" { + 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) From 6d5cf0ead8a6c4fef0e0d3edb5537cc71be6e1fc Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:56:29 +0900 Subject: [PATCH 39/53] Prepare authenticated beacon evidence records --- cmd/mpc-ceremony/beacon_evidence_ops_test.go | 91 +++++++++++++++ cmd/mpc-ceremony/executor.go | 2 + cmd/mpc-ceremony/main.go | 2 +- cmd/mpc-ceremony/ops.go | 116 +++++++++++++++++++ cmd/mpc-ceremony/parse.go | 30 +++++ cmd/mpc-ceremony/types.go | 13 +++ cmd/mpc-ceremony/usage.go | 17 +++ 7 files changed, 270 insertions(+), 1 deletion(-) create mode 100644 cmd/mpc-ceremony/beacon_evidence_ops_test.go diff --git a/cmd/mpc-ceremony/beacon_evidence_ops_test.go b/cmd/mpc-ceremony/beacon_evidence_ops_test.go new file mode 100644 index 00000000..049a8acb --- /dev/null +++ b/cmd/mpc-ceremony/beacon_evidence_ops_test.go @@ -0,0 +1,91 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "proof-tool/internal/mpcceremony" +) + +const commandQuicknetRound = `{"round":31000000,"randomness":"b83329945edcd19e76cdc0f8c44afcac406df603247ef357b93924673da8f9a5","signature":"a43f1eab1da28f3f95220709d12a61cc0f1fed4a9ffe7cb4948e3bc3777de670e837501cdc70c903a6189c0639871985"}` + +func TestPrepareBeaconEvidenceVerifiesDistinctOperatorsAndRawResponses(t *testing.T) { + root := t.TempDir() + root, err := filepath.EvalSymlinks(root) + if err != nil { + t.Fatal(err) + } + definition, _, coordinatorKey := decisionSignFixture(t) + trust := trustOptionsFromArgs(t, writeInspectionTrustFixture(t, root, definition, coordinatorKey)) + roundTime, err := mpcceremony.QuicknetRoundTime(31_000_000) + if err != nil { + t.Fatal(err) + } + closeRecord, err := mpcceremony.NewCloseRecord(mpcceremony.CloseRecord{ + CeremonyID: definition.CeremonyID, Phase: mpcceremony.Phase1, + PhaseID: "sha256:" + strings.Repeat("44", 32), FinalIndex: 1, + FinalPayload: commandArtifact("phase1/final.bin", "final"), ChainHeadID: "sha256:" + strings.Repeat("55", 32), + AcceptedParticipants: []string{definition.Roster[0].Identity.ID}, BeaconProvider: definition.BeaconPolicy.Provider, + BeaconNetwork: definition.BeaconPolicy.Network, BeaconRound: 31_000_000, BeaconNotBefore: roundTime.Format(time.RFC3339), + ClosedAt: roundTime.Add(-25 * time.Hour).Format(time.RFC3339), CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + }) + if err != nil { + t.Fatal(err) + } + closeBytes, closeSignature, err := mpcceremony.SignRecord(closeRecord, definition.Coordinator.KeyID, coordinatorKey) + if err != nil { + t.Fatal(err) + } + closure := filepath.Join(root, "phase1", "closure", "record.json") + closureSignature := filepath.Join(root, "phase1", "closure", "record.sig") + if err := os.MkdirAll(filepath.Dir(closure), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, closure, closeBytes, 0o600) + writeDecisionTestFile(t, closureSignature, closeSignature, 0o600) + if err := os.MkdirAll(filepath.Join(root, "phase1", "beacon", "raw"), 0o700); err != nil { + t.Fatal(err) + } + for _, name := range []string{"protocol-labs.json", "cloudflare.json"} { + writeDecisionTestFile(t, filepath.Join(root, "phase1", "beacon", "raw", name), []byte(commandQuicknetRound), 0o600) + } + inputs := beaconObservationInputSet{Schema: beaconObservationInputSchema, Observations: []beaconObservationInput{ + {RelayID: "cloudflare-relay", OperatorID: "cloudflare", Endpoint: "https://drand.cloudflare.com", RawResponseName: "phase1/beacon/raw/cloudflare.json", RetrievedAt: roundTime.Add(time.Minute).Format(time.RFC3339)}, + {RelayID: "protocol-labs-relay", OperatorID: "protocol-labs", Endpoint: "https://api.drand.sh", RawResponseName: "phase1/beacon/raw/protocol-labs.json", RetrievedAt: roundTime.Add(time.Minute).Format(time.RFC3339)}, + }} + inputBytes, err := json.Marshal(inputs) + if err != nil { + t.Fatal(err) + } + inputPath := filepath.Join(root, "observations.json") + writeDecisionTestFile(t, inputPath, inputBytes, 0o600) + options := OpsPrepareBeaconEvidenceOptions{CeremonyPath: trust.CeremonyPath, CeremonySignaturePath: trust.CeremonySignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyFile, TranscriptRoot: root, ClosurePath: closure, ClosureSignaturePath: closureSignature, ObservationsPath: inputPath, RecordedAt: roundTime.Add(2 * time.Minute).Format(time.RFC3339), OutDir: filepath.Join(root, "prepared")} + result, err := executeOpsPrepareBeaconEvidence(options) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(result.Outputs["canonical"]) + if err != nil { + t.Fatal(err) + } + var evidence mpcceremony.MultiRelayBeaconEvidence + if err := mpcceremony.UnmarshalCanonical(raw, &evidence); err != nil { + t.Fatal(err) + } + if len(evidence.Observations) != 2 || evidence.Observations[0].RelayID != "cloudflare-relay" || evidence.Observations[1].RelayID != "protocol-labs-relay" { + t.Fatalf("unexpected canonical observations: %#v", evidence.Observations) + } + + inputs.Observations[1].OperatorID = inputs.Observations[0].OperatorID + inputBytes, _ = json.Marshal(inputs) + writeDecisionTestFile(t, inputPath, inputBytes, 0o600) + options.OutDir = filepath.Join(root, "rejected") + if _, err := executeOpsPrepareBeaconEvidence(options); err == nil { + t.Fatal("same-operator relay observations unexpectedly accepted") + } +} diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index fe3a0831..5b6c8f31 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -81,6 +81,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeOpsPreparePublicWitnessReceipt(invocation.Options.(OpsPreparePublicWitnessReceiptOptions)) case CommandOpsPrepareMirrorReceipt: return executeOpsPrepareMirrorReceipt(invocation.Options.(OpsPrepareMirrorReceiptOptions)) + case CommandOpsPrepareBeaconEvidence: + return executeOpsPrepareBeaconEvidence(invocation.Options.(OpsPrepareBeaconEvidenceOptions)) case CommandOpsExportSigning: return executeOpsExportSigning(invocation.Options.(OpsExportSigningOptions)) case CommandOpsPrepareEnrollment: diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 783d97a6..4ad96c32 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -277,7 +277,7 @@ command: }, "ops": { "export-signing": {}, "help": {}, "import-signature": {}, "sign": {}, "prepare-enrollment": {}, "prepare-handoff": {}, "prepare-receipt": {}, - "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "prepare-bundle": {}, "prepare-bundle-v4": {}, "sign-bundle-v4": {}, "verify": {}, + "prepare-beacon-evidence": {}, "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": {}, "review-v4": {}}, diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index c5f05d90..5683c847 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -4,20 +4,42 @@ package main import ( + "bytes" "crypto/ed25519" + "crypto/sha256" "encoding/hex" + "encoding/json" "errors" "fmt" "io" "io/fs" + "net/url" "os" "path/filepath" + "sort" + "strings" + "time" "proof-tool/internal/mpcceremony" ) const maxOperationalRecordBytes = 16 << 20 +const beaconObservationInputSchema = "proof-tool-mpc-beacon-observation-input-v1" + +type beaconObservationInput struct { + RelayID string `json:"relay_id"` + OperatorID string `json:"operator_id"` + Endpoint string `json:"endpoint"` + RawResponseName string `json:"raw_response_name"` + RetrievedAt string `json:"retrieved_at"` +} + +type beaconObservationInputSet struct { + Schema string `json:"schema"` + Observations []beaconObservationInput `json:"observations"` +} + func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceiptOptions) (CommandResult, error) { trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ DefinitionPath: options.CeremonyPath, @@ -92,6 +114,100 @@ func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceip }, nil } +func executeOpsPrepareBeaconEvidence(options OpsPrepareBeaconEvidenceOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }) + if err != nil { + return CommandResult{}, err + } + closure, _, err := mpcceremony.LoadSignedCloseExact(trusted, options.TranscriptRoot, options.ClosurePath, options.ClosureSignaturePath) + if err != nil { + return CommandResult{}, err + } + recordedAt, err := time.Parse(time.RFC3339Nano, options.RecordedAt) + if err != nil || recordedAt.IsZero() || recordedAt.Location() != time.UTC { + return CommandResult{}, errors.New("recorded-at must be a nonzero canonical UTC timestamp") + } + rawInputs, err := readRegularOperationalFile(options.ObservationsPath, 1<<20) + if err != nil { + return CommandResult{}, err + } + decoder := json.NewDecoder(bytes.NewReader(rawInputs)) + decoder.DisallowUnknownFields() + var inputs beaconObservationInputSet + if err := decoder.Decode(&inputs); err != nil { + return CommandResult{}, fmt.Errorf("decode beacon observations: %w", err) + } + if decoder.Decode(&struct{}{}) != io.EOF { + return CommandResult{}, errors.New("beacon observations contain trailing JSON") + } + if inputs.Schema != beaconObservationInputSchema || len(inputs.Observations) < 2 || len(inputs.Observations) > 16 { + return CommandResult{}, errors.New("beacon observations require the supported schema and between 2 and 16 entries") + } + observations := make([]mpcceremony.RelayObservation, 0, len(inputs.Observations)) + responses := make(map[string][]byte, len(inputs.Observations)) + for index, input := range inputs.Observations { + endpoint, err := url.Parse(input.Endpoint) + if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { + return CommandResult{}, fmt.Errorf("observation %d endpoint must be a credential-free HTTPS URL without query or fragment", index) + } + name := filepath.ToSlash(filepath.Clean(filepath.FromSlash(input.RawResponseName))) + if input.RawResponseName == "" || name != input.RawResponseName || filepath.IsAbs(filepath.FromSlash(name)) || name == "." || name == ".." || strings.HasPrefix(name, "../") { + return CommandResult{}, fmt.Errorf("observation %d raw response name must be a clean relative artifact name", index) + } + raw, err := openCheckpointArtifactBytes(options.TranscriptRoot, name, 1<<20) + if err != nil { + return CommandResult{}, fmt.Errorf("observation %d raw response: %w", index, err) + } + retrievedAt, err := time.Parse(time.RFC3339Nano, input.RetrievedAt) + if err != nil || retrievedAt.IsZero() || retrievedAt.Location() != time.UTC { + return CommandResult{}, fmt.Errorf("observation %d retrieved_at must be a nonzero canonical UTC timestamp", index) + } + randomness, err := mpcceremony.VerifyDrandBeaconResponse(trusted.Definition.BeaconPolicy, closure.Record.BeaconRound, raw) + if err != nil { + return CommandResult{}, fmt.Errorf("observation %d drand response: %w", index, err) + } + hash := sha256.Sum256([]byte(input.Endpoint)) + observations = append(observations, mpcceremony.RelayObservation{ + RelayID: input.RelayID, + OperatorID: input.OperatorID, + EndpointSHA256: "sha256:" + hex.EncodeToString(hash[:]), + RawResponse: mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.NewDigest(raw)}, + RetrievedAt: input.RetrievedAt, + VerifiedRandomness: randomness, + }) + if _, duplicate := responses[input.RelayID]; duplicate { + return CommandResult{}, fmt.Errorf("observation %d duplicates relay_id", index) + } + responses[input.RelayID] = raw + } + sort.Slice(observations, func(i, j int) bool { return observations[i].RelayID < observations[j].RelayID }) + record, err := mpcceremony.NewMultiRelayBeaconEvidence(trusted.Definition, closure.Record, observations, responses, options.RecordedAt) + if err != nil { + return CommandResult{}, err + } + canonical, err := mpcceremony.MarshalCanonical(record) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordBeaconEvidence, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Phase: string(closure.Record.Phase), Summary: "verified distinct-operator drand responses and exported canonical multi-relay evidence", Outputs: map[string]string{"canonical": canonicalPath, "signing_request": requestPath}}, nil +} + func executeOpsPrepareMirrorReceipt(options OpsPrepareMirrorReceiptOptions) (CommandResult, error) { trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ DefinitionPath: options.CeremonyPath, diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index cf88fc1a..013f2812 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -602,6 +602,10 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { options, err := parseOpsPrepareMirrorReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPrepareMirrorReceipt, options return invocation, wrapCommandError(err, "ops", "prepare-mirror-receipt") + case "prepare-beacon-evidence": + options, err := parseOpsPrepareBeaconEvidence(args[1:]) + invocation.Command, invocation.Options = CommandOpsPrepareBeaconEvidence, options + return invocation, wrapCommandError(err, "ops", "prepare-beacon-evidence") case "export-signing": options, err := parseOpsExportSigning(args[1:]) invocation.Command, invocation.Options = CommandOpsExportSigning, options @@ -622,6 +626,32 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { } } +func parseOpsPrepareBeaconEvidence(args []string) (OpsPrepareBeaconEvidenceOptions, error) { + var options OpsPrepareBeaconEvidenceOptions + fs := commandFlagSet("ops prepare-beacon-evidence") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local root containing the signed closure and raw relay responses") + fs.StringVar(&options.ClosurePath, "closure", "", "exact coordinator-signed closure record") + fs.StringVar(&options.ClosureSignaturePath, "closure-signature", "", "detached coordinator signature for the closure") + fs.StringVar(&options.ObservationsPath, "observations", "", "strict JSON describing independently operated relay endpoints and retained raw response names") + fs.StringVar(&options.RecordedAt, "recorded-at", "", "evidence preparation time in RFC3339 UTC") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for canonical evidence and signing request") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-root", options.TranscriptRoot), + pathValue("--closure", options.ClosurePath), + pathValue("--closure-signature", options.ClosureSignaturePath), + pathValue("--observations", options.ObservationsPath), + value("--recorded-at", options.RecordedAt), + pathValue("--out-dir", options.OutDir), + ) +} + func parseOpsPreparePublicWitnessReceipt(args []string) (OpsPreparePublicWitnessReceiptOptions, error) { var options OpsPreparePublicWitnessReceiptOptions fs := commandFlagSet("ops prepare-public-witness-receipt") diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 0fd96300..9f544db9 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -42,6 +42,7 @@ const ( CommandReleaseReviewV4 Command = "release review-v4" CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" + CommandOpsPrepareBeaconEvidence Command = "ops prepare-beacon-evidence" CommandOpsExportSigning Command = "ops export-signing" CommandOpsPrepareEnrollment Command = "ops prepare-enrollment" CommandOpsPrepareBundle Command = "ops prepare-bundle" @@ -312,6 +313,18 @@ type OpsPreparePublicWitnessReceiptOptions struct { OutDir string } +type OpsPrepareBeaconEvidenceOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + TranscriptRoot string + ClosurePath string + ClosureSignaturePath string + ObservationsPath string + RecordedAt string + OutDir string +} + type OpsImportSignatureOptions struct { RecordType string CanonicalPath string diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index b6c4d9cd..5953c6cd 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -73,6 +73,7 @@ Commands: ops sign Sign your reviewed enrollment or observation offline ops prepare-public-witness-receipt Prepare witnessed closure bytes ops prepare-mirror-receipt Authenticate a relay draft for offline signing + ops prepare-beacon-evidence Verify multiple relay responses and prepare evidence ops export-signing Export canonical operational bytes for offline signing ops import-signature Import and verify a raw offline Ed25519 signature ops verify Verify a signed operational record fail-closed @@ -854,6 +855,22 @@ Authenticates the exact accepted chain prefix and the mirror operator's signed proof-of-possession enrollment, recomputes every receipt file reference, and requires the relay draft to match. It then exports canonical.json and signing-request.json without reading a private signing key. +`, + "ops prepare-beacon-evidence": `Usage: + mpc-ceremony ops prepare-beacon-evidence \ + --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root DIR \ + --closure FILE --closure-signature FILE \ + --observations FILE --recorded-at RFC3339_UTC \ + --out-dir FRESH_DIR + +Authenticates the signed closure, then reads a strict observation-input file. +Each observation names a relay ID, operator ID, HTTPS endpoint, retrieval time, +and raw response path relative to the transcript root. At least two distinct +operators and endpoints are required. The command verifies every response's +drand signature and round, requires identical randomness, hashes endpoint +identities, and exports canonical.json plus signing-request.json. It performs +no network access and does not read a private signing key. `, "ops prepare-enrollment": `Usage: mpc-ceremony ops prepare-enrollment --ceremony FILE --ceremony-signature FILE \ From e729f69e15a09212965f1cc99e828696075d4d03 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 18:56:29 +0900 Subject: [PATCH 40/53] Expose closed final release download inventory --- .../mpcceremony/checkpoint_v4_commitments.go | 10 +++- internal/mpcceremony/checkpoint_v4_release.go | 56 +++++++++++++++++++ .../mpcceremony/checkpoint_v4_release_test.go | 48 ++++++++++++++++ 3 files changed, 111 insertions(+), 3 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go index ea543db8..950da5db 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments.go +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -10,8 +10,9 @@ import ( // 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"` + Enrollments []SignedArtifactRefs `json:"enrollments"` + Turns []TurnCommitmentV4 `json:"turns"` + FinalReleaseArtifacts []ArtifactRef `json:"final_release_artifacts"` } type CandidateAllocationV4 struct { @@ -80,6 +81,9 @@ func InspectStoredCheckpointV4(trust TrustPaths, root string, head SignedArtifac } 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 } @@ -87,7 +91,7 @@ func checkpointCommitmentsV4(a checkpointAncestryV4) (CheckpointCommitmentsV4, e if len(a.enrollments) > 128 { return CheckpointCommitmentsV4{}, errors.New("enrollment commitment index exceeds protocol capacity") } - index := CheckpointCommitmentsV4{Enrollments: sortedSignedRefsV4(a.enrollments), Turns: []TurnCommitmentV4{}} + index := CheckpointCommitmentsV4{Enrollments: sortedSignedRefsV4(a.enrollments), Turns: []TurnCommitmentV4{}, FinalReleaseArtifacts: []ArtifactRef{}} for _, turn := range a.turnCommitments { index.Turns = append(index.Turns, *turn) } diff --git a/internal/mpcceremony/checkpoint_v4_release.go b/internal/mpcceremony/checkpoint_v4_release.go index 5e73bc28..24c78026 100644 --- a/internal/mpcceremony/checkpoint_v4_release.go +++ b/internal/mpcceremony/checkpoint_v4_release.go @@ -97,6 +97,62 @@ func requireReleaseReviewPredecessorV4(review ReleaseReviewV4, c CheckpointV4) e 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") + } + record, _, err := reader.pair(*c.Progress.ReleaseReview) + if err != nil { + return nil, err + } + var review ReleaseReviewV4 + if err := UnmarshalCanonical(record, &review); err != nil { + return nil, err + } + 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. diff --git a/internal/mpcceremony/checkpoint_v4_release_test.go b/internal/mpcceremony/checkpoint_v4_release_test.go index b0028675..f6568ad6 100644 --- a/internal/mpcceremony/checkpoint_v4_release_test.go +++ b/internal/mpcceremony/checkpoint_v4_release_test.go @@ -2,6 +2,8 @@ package mpcceremony import ( "fmt" + "path/filepath" + "slices" "strings" "testing" @@ -13,6 +15,52 @@ func releaseTransitionFixtureV4() CheckpointTransitionV4 { 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) { + root := t.TempDir() + reviewCheckpoint := checkpointSigned("checkpoints/review") + required := checkpointArtifacts(checkpointArtifact("ceremony.json", "definition"), checkpointArtifact("final/candidate/candidate.json", "candidate")) + review := ReleaseReviewV4{ + CeremonyID: "sha256:" + strings.Repeat("a", 64), ReviewCheckpoint: reviewCheckpoint, + FinalCandidateCheckpoint: checkpointSigned("checkpoints/candidate"), CandidateArtifacts: []ArtifactRef{required[1]}, + RequiredArtifacts: required, OperationalBundle: checkpointSigned("operational/bundle"), Audits: []SignedArtifactRefs{}, + ReplayVerification: CheckpointReplayVerificationV4{Method: CoordinatorReplayReleaseV1, ToolBinary: NewDigest([]byte("binary"))}, + ReleasedAt: "2026-07-23T16:00:00Z", + } + raw, err := MarshalCanonical(review) + if err != nil { + t.Fatal(err) + } + signature := []byte("review signature") + pair := SignedArtifactRefs{Record: ArtifactRef{Name: "final/review/record.json", Digest: NewDigest(raw)}, Signature: ArtifactRef{Name: "final/review/record.sig", Digest: NewDigest(signature)}} + writeFixtureFile(t, root, pair.Record.Name, raw) + writeFixtureFile(t, root, pair.Signature.Name, signature) + tx := releaseTransitionFixtureV4() + head := CheckpointV4{PreviousCheckpoint: &reviewCheckpoint, Transition: tx, Progress: CheckpointProgressV4{ReleaseReview: &pair, FinalRelease: tx.Record}} + reader, err := openCheckpointReaderV4(root) + if err != nil { + t.Fatal(err) + } + defer reader.root.Close() + refs, err := finalReleaseDownloadArtifactsV4(reader, checkpointAncestryV4{head: head}) + if err != nil { + t.Fatal(err) + } + want := []string{ + "final/release/candidate.json", "final/release/ceremony.json", "final/release/checksums.sha256", + "final/release/manifest-public-key.hex", "final/release/manifest.json", "final/release/manifest.sig", "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) + } +} + func TestFinalReleaseV4CanonicalBootstrap(t *testing.T) { tx := releaseTransitionFixtureV4() if err := tx.Validate(); err != nil { From fec26a6d8f461adca869184d852b42a67cc1053a Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 19:05:40 +0900 Subject: [PATCH 41/53] Expose committed beacon evidence by phase --- .../mpcceremony/checkpoint_v4_commitments.go | 37 ++++++++++++++++++- 1 file changed, 36 insertions(+), 1 deletion(-) diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go index 950da5db..e03e8382 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments.go +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -12,9 +12,15 @@ import ( type CheckpointCommitmentsV4 struct { Enrollments []SignedArtifactRefs `json:"enrollments"` Turns []TurnCommitmentV4 `json:"turns"` + BeaconEvidence []PhaseCommitmentV4 `json:"beacon_evidence"` FinalReleaseArtifacts []ArtifactRef `json:"final_release_artifacts"` } +type PhaseCommitmentV4 struct { + Phase Phase `json:"phase"` + Pair SignedArtifactRefs `json:"pair"` +} + type CandidateAllocationV4 struct { CheckpointSequence uint64 `json:"checkpoint_sequence"` Checkpoint SignedArtifactRefs `json:"checkpoint"` @@ -81,6 +87,9 @@ func InspectStoredCheckpointV4(trust TrustPaths, root string, head SignedArtifac } defer func() { _ = c.reader.root.Close() }() index, err := checkpointCommitmentsV4(c.ancestry) + if err == nil { + index.BeaconEvidence, err = beaconEvidenceCommitmentsV4(c.reader, c.trusted.Definition, c.ancestry) + } if err == nil { index.FinalReleaseArtifacts, err = finalReleaseDownloadArtifactsV4(c.reader, c.ancestry) } @@ -91,7 +100,7 @@ func checkpointCommitmentsV4(a checkpointAncestryV4) (CheckpointCommitmentsV4, e 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{}} + index := CheckpointCommitmentsV4{Enrollments: sortedSignedRefsV4(a.enrollments), Turns: []TurnCommitmentV4{}, BeaconEvidence: []PhaseCommitmentV4{}, FinalReleaseArtifacts: []ArtifactRef{}} for _, turn := range a.turnCommitments { index.Turns = append(index.Turns, *turn) } @@ -103,3 +112,29 @@ func checkpointCommitmentsV4(a checkpointAncestryV4) (CheckpointCommitmentsV4, e }) return index, nil } + +func beaconEvidenceCommitmentsV4(reader *checkpointReaderV4, definition CeremonyDefinition, ancestry checkpointAncestryV4) ([]PhaseCommitmentV4, error) { + key, err := identityPublicKey(definition.Coordinator) + if err != nil { + return nil, err + } + result := make([]PhaseCommitmentV4, 0, len(ancestry.beaconEvidence)) + for _, pair := range ancestry.beaconEvidence { + record, signature, err := reader.pair(pair) + if err != nil { + return nil, err + } + var evidence MultiRelayBeaconEvidence + if err := VerifySignedRecord(record, signature, &evidence, definition.Coordinator.KeyID, key); err != nil { + return nil, err + } + result = append(result, PhaseCommitmentV4{Phase: evidence.Phase, Pair: pair}) + } + slices.SortFunc(result, func(a, b PhaseCommitmentV4) int { return strings.Compare(string(a.Phase), string(b.Phase)) }) + for i := 1; i < len(result); i++ { + if result[i-1].Phase == result[i].Phase { + return nil, errors.New("duplicate beacon evidence commitment for phase") + } + } + return result, nil +} From d2c77079d352dd632dfce195a389448972379e09 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 20:58:06 +0900 Subject: [PATCH 42/53] Simplify V4 beacon evidence --- cmd/mpc-ceremony/beacon_evidence_ops_test.go | 91 -------------- cmd/mpc-ceremony/checkpoint_v4.go | 2 +- cmd/mpc-ceremony/checkpoint_v4_test.go | 2 +- cmd/mpc-ceremony/executor.go | 2 - cmd/mpc-ceremony/main.go | 2 +- cmd/mpc-ceremony/ops.go | 116 ------------------ cmd/mpc-ceremony/parse.go | 30 ----- cmd/mpc-ceremony/types.go | 13 -- cmd/mpc-ceremony/usage.go | 31 ++--- docs/ceremony-schema-compatibility.md | 4 + docs/trusted-setup-ceremony.md | 18 ++- internal/mpcceremony/checkpoint_v4.go | 7 +- .../checkpoint_v4_beacon_evidence.go | 54 -------- internal/mpcceremony/checkpoint_v4_bundle.go | 24 ++-- .../mpcceremony/checkpoint_v4_commitments.go | 37 +----- internal/mpcceremony/checkpoint_v4_files.go | 19 +-- .../mpcceremony/checkpoint_v4_files_test.go | 1 - internal/mpcceremony/checkpoint_v4_record.go | 2 +- internal/mpcceremony/operational_bundle.go | 110 +++++++++++++---- .../mpcceremony/operational_bundle_test.go | 31 +++++ .../testdata/workflowhelper/checkpoint_v4.go | 27 ---- .../workflowhelper/checkpoint_v4_final.go | 25 ---- 22 files changed, 164 insertions(+), 484 deletions(-) delete mode 100644 cmd/mpc-ceremony/beacon_evidence_ops_test.go diff --git a/cmd/mpc-ceremony/beacon_evidence_ops_test.go b/cmd/mpc-ceremony/beacon_evidence_ops_test.go deleted file mode 100644 index 049a8acb..00000000 --- a/cmd/mpc-ceremony/beacon_evidence_ops_test.go +++ /dev/null @@ -1,91 +0,0 @@ -package main - -import ( - "encoding/json" - "os" - "path/filepath" - "strings" - "testing" - "time" - - "proof-tool/internal/mpcceremony" -) - -const commandQuicknetRound = `{"round":31000000,"randomness":"b83329945edcd19e76cdc0f8c44afcac406df603247ef357b93924673da8f9a5","signature":"a43f1eab1da28f3f95220709d12a61cc0f1fed4a9ffe7cb4948e3bc3777de670e837501cdc70c903a6189c0639871985"}` - -func TestPrepareBeaconEvidenceVerifiesDistinctOperatorsAndRawResponses(t *testing.T) { - root := t.TempDir() - root, err := filepath.EvalSymlinks(root) - if err != nil { - t.Fatal(err) - } - definition, _, coordinatorKey := decisionSignFixture(t) - trust := trustOptionsFromArgs(t, writeInspectionTrustFixture(t, root, definition, coordinatorKey)) - roundTime, err := mpcceremony.QuicknetRoundTime(31_000_000) - if err != nil { - t.Fatal(err) - } - closeRecord, err := mpcceremony.NewCloseRecord(mpcceremony.CloseRecord{ - CeremonyID: definition.CeremonyID, Phase: mpcceremony.Phase1, - PhaseID: "sha256:" + strings.Repeat("44", 32), FinalIndex: 1, - FinalPayload: commandArtifact("phase1/final.bin", "final"), ChainHeadID: "sha256:" + strings.Repeat("55", 32), - AcceptedParticipants: []string{definition.Roster[0].Identity.ID}, BeaconProvider: definition.BeaconPolicy.Provider, - BeaconNetwork: definition.BeaconPolicy.Network, BeaconRound: 31_000_000, BeaconNotBefore: roundTime.Format(time.RFC3339), - ClosedAt: roundTime.Add(-25 * time.Hour).Format(time.RFC3339), CoordinatorID: definition.Coordinator.ID, - CoordinatorKeyID: definition.Coordinator.KeyID, - }) - if err != nil { - t.Fatal(err) - } - closeBytes, closeSignature, err := mpcceremony.SignRecord(closeRecord, definition.Coordinator.KeyID, coordinatorKey) - if err != nil { - t.Fatal(err) - } - closure := filepath.Join(root, "phase1", "closure", "record.json") - closureSignature := filepath.Join(root, "phase1", "closure", "record.sig") - if err := os.MkdirAll(filepath.Dir(closure), 0o700); err != nil { - t.Fatal(err) - } - writeDecisionTestFile(t, closure, closeBytes, 0o600) - writeDecisionTestFile(t, closureSignature, closeSignature, 0o600) - if err := os.MkdirAll(filepath.Join(root, "phase1", "beacon", "raw"), 0o700); err != nil { - t.Fatal(err) - } - for _, name := range []string{"protocol-labs.json", "cloudflare.json"} { - writeDecisionTestFile(t, filepath.Join(root, "phase1", "beacon", "raw", name), []byte(commandQuicknetRound), 0o600) - } - inputs := beaconObservationInputSet{Schema: beaconObservationInputSchema, Observations: []beaconObservationInput{ - {RelayID: "cloudflare-relay", OperatorID: "cloudflare", Endpoint: "https://drand.cloudflare.com", RawResponseName: "phase1/beacon/raw/cloudflare.json", RetrievedAt: roundTime.Add(time.Minute).Format(time.RFC3339)}, - {RelayID: "protocol-labs-relay", OperatorID: "protocol-labs", Endpoint: "https://api.drand.sh", RawResponseName: "phase1/beacon/raw/protocol-labs.json", RetrievedAt: roundTime.Add(time.Minute).Format(time.RFC3339)}, - }} - inputBytes, err := json.Marshal(inputs) - if err != nil { - t.Fatal(err) - } - inputPath := filepath.Join(root, "observations.json") - writeDecisionTestFile(t, inputPath, inputBytes, 0o600) - options := OpsPrepareBeaconEvidenceOptions{CeremonyPath: trust.CeremonyPath, CeremonySignaturePath: trust.CeremonySignaturePath, CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyFile, TranscriptRoot: root, ClosurePath: closure, ClosureSignaturePath: closureSignature, ObservationsPath: inputPath, RecordedAt: roundTime.Add(2 * time.Minute).Format(time.RFC3339), OutDir: filepath.Join(root, "prepared")} - result, err := executeOpsPrepareBeaconEvidence(options) - if err != nil { - t.Fatal(err) - } - raw, err := os.ReadFile(result.Outputs["canonical"]) - if err != nil { - t.Fatal(err) - } - var evidence mpcceremony.MultiRelayBeaconEvidence - if err := mpcceremony.UnmarshalCanonical(raw, &evidence); err != nil { - t.Fatal(err) - } - if len(evidence.Observations) != 2 || evidence.Observations[0].RelayID != "cloudflare-relay" || evidence.Observations[1].RelayID != "protocol-labs-relay" { - t.Fatalf("unexpected canonical observations: %#v", evidence.Observations) - } - - inputs.Observations[1].OperatorID = inputs.Observations[0].OperatorID - inputBytes, _ = json.Marshal(inputs) - writeDecisionTestFile(t, inputPath, inputBytes, 0o600) - options.OutDir = filepath.Join(root, "rejected") - if _, err := executeOpsPrepareBeaconEvidence(options); err == nil { - t.Fatal("same-operator relay observations unexpectedly accepted") - } -} diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index 1e432a4f..6516d7c3 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -147,7 +147,7 @@ func checkpointNeedsCircuitV4(kind m.CheckpointTransitionKind) (bool, error) { 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.CheckpointBeaconEvidenceRecorded, m.CheckpointAuditRecorded, + m.CheckpointWitnessRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted: return false, nil default: diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index cdceea84..4218c8af 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -21,7 +21,7 @@ func TestCheckpointV4CircuitClassification(t *testing.T) { 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.CheckpointBeaconEvidenceRecorded, m.CheckpointAuditRecorded, m.CheckpointIncidentRecorded, m.CheckpointAborted, m.CheckpointRestarted}}, + {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 { diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 5b6c8f31..fe3a0831 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -81,8 +81,6 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeOpsPreparePublicWitnessReceipt(invocation.Options.(OpsPreparePublicWitnessReceiptOptions)) case CommandOpsPrepareMirrorReceipt: return executeOpsPrepareMirrorReceipt(invocation.Options.(OpsPrepareMirrorReceiptOptions)) - case CommandOpsPrepareBeaconEvidence: - return executeOpsPrepareBeaconEvidence(invocation.Options.(OpsPrepareBeaconEvidenceOptions)) case CommandOpsExportSigning: return executeOpsExportSigning(invocation.Options.(OpsExportSigningOptions)) case CommandOpsPrepareEnrollment: diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 4ad96c32..783d97a6 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -277,7 +277,7 @@ command: }, "ops": { "export-signing": {}, "help": {}, "import-signature": {}, "sign": {}, "prepare-enrollment": {}, "prepare-handoff": {}, "prepare-receipt": {}, - "prepare-beacon-evidence": {}, "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "prepare-bundle": {}, "prepare-bundle-v4": {}, "sign-bundle-v4": {}, "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": {}, "review-v4": {}}, diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index 5683c847..c5f05d90 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -4,42 +4,20 @@ package main import ( - "bytes" "crypto/ed25519" - "crypto/sha256" "encoding/hex" - "encoding/json" "errors" "fmt" "io" "io/fs" - "net/url" "os" "path/filepath" - "sort" - "strings" - "time" "proof-tool/internal/mpcceremony" ) const maxOperationalRecordBytes = 16 << 20 -const beaconObservationInputSchema = "proof-tool-mpc-beacon-observation-input-v1" - -type beaconObservationInput struct { - RelayID string `json:"relay_id"` - OperatorID string `json:"operator_id"` - Endpoint string `json:"endpoint"` - RawResponseName string `json:"raw_response_name"` - RetrievedAt string `json:"retrieved_at"` -} - -type beaconObservationInputSet struct { - Schema string `json:"schema"` - Observations []beaconObservationInput `json:"observations"` -} - func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceiptOptions) (CommandResult, error) { trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ DefinitionPath: options.CeremonyPath, @@ -114,100 +92,6 @@ func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceip }, nil } -func executeOpsPrepareBeaconEvidence(options OpsPrepareBeaconEvidenceOptions) (CommandResult, error) { - trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ - DefinitionPath: options.CeremonyPath, - DefinitionSignaturePath: options.CeremonySignaturePath, - CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, - }) - if err != nil { - return CommandResult{}, err - } - closure, _, err := mpcceremony.LoadSignedCloseExact(trusted, options.TranscriptRoot, options.ClosurePath, options.ClosureSignaturePath) - if err != nil { - return CommandResult{}, err - } - recordedAt, err := time.Parse(time.RFC3339Nano, options.RecordedAt) - if err != nil || recordedAt.IsZero() || recordedAt.Location() != time.UTC { - return CommandResult{}, errors.New("recorded-at must be a nonzero canonical UTC timestamp") - } - rawInputs, err := readRegularOperationalFile(options.ObservationsPath, 1<<20) - if err != nil { - return CommandResult{}, err - } - decoder := json.NewDecoder(bytes.NewReader(rawInputs)) - decoder.DisallowUnknownFields() - var inputs beaconObservationInputSet - if err := decoder.Decode(&inputs); err != nil { - return CommandResult{}, fmt.Errorf("decode beacon observations: %w", err) - } - if decoder.Decode(&struct{}{}) != io.EOF { - return CommandResult{}, errors.New("beacon observations contain trailing JSON") - } - if inputs.Schema != beaconObservationInputSchema || len(inputs.Observations) < 2 || len(inputs.Observations) > 16 { - return CommandResult{}, errors.New("beacon observations require the supported schema and between 2 and 16 entries") - } - observations := make([]mpcceremony.RelayObservation, 0, len(inputs.Observations)) - responses := make(map[string][]byte, len(inputs.Observations)) - for index, input := range inputs.Observations { - endpoint, err := url.Parse(input.Endpoint) - if err != nil || endpoint.Scheme != "https" || endpoint.Host == "" || endpoint.User != nil || endpoint.RawQuery != "" || endpoint.Fragment != "" { - return CommandResult{}, fmt.Errorf("observation %d endpoint must be a credential-free HTTPS URL without query or fragment", index) - } - name := filepath.ToSlash(filepath.Clean(filepath.FromSlash(input.RawResponseName))) - if input.RawResponseName == "" || name != input.RawResponseName || filepath.IsAbs(filepath.FromSlash(name)) || name == "." || name == ".." || strings.HasPrefix(name, "../") { - return CommandResult{}, fmt.Errorf("observation %d raw response name must be a clean relative artifact name", index) - } - raw, err := openCheckpointArtifactBytes(options.TranscriptRoot, name, 1<<20) - if err != nil { - return CommandResult{}, fmt.Errorf("observation %d raw response: %w", index, err) - } - retrievedAt, err := time.Parse(time.RFC3339Nano, input.RetrievedAt) - if err != nil || retrievedAt.IsZero() || retrievedAt.Location() != time.UTC { - return CommandResult{}, fmt.Errorf("observation %d retrieved_at must be a nonzero canonical UTC timestamp", index) - } - randomness, err := mpcceremony.VerifyDrandBeaconResponse(trusted.Definition.BeaconPolicy, closure.Record.BeaconRound, raw) - if err != nil { - return CommandResult{}, fmt.Errorf("observation %d drand response: %w", index, err) - } - hash := sha256.Sum256([]byte(input.Endpoint)) - observations = append(observations, mpcceremony.RelayObservation{ - RelayID: input.RelayID, - OperatorID: input.OperatorID, - EndpointSHA256: "sha256:" + hex.EncodeToString(hash[:]), - RawResponse: mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.NewDigest(raw)}, - RetrievedAt: input.RetrievedAt, - VerifiedRandomness: randomness, - }) - if _, duplicate := responses[input.RelayID]; duplicate { - return CommandResult{}, fmt.Errorf("observation %d duplicates relay_id", index) - } - responses[input.RelayID] = raw - } - sort.Slice(observations, func(i, j int) bool { return observations[i].RelayID < observations[j].RelayID }) - record, err := mpcceremony.NewMultiRelayBeaconEvidence(trusted.Definition, closure.Record, observations, responses, options.RecordedAt) - if err != nil { - return CommandResult{}, err - } - canonical, err := mpcceremony.MarshalCanonical(record) - if err != nil { - return CommandResult{}, err - } - request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordBeaconEvidence, canonical) - if err != nil { - return CommandResult{}, err - } - requestBytes, err := mpcceremony.MarshalCanonical(request) - if err != nil { - return CommandResult{}, err - } - canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) - if err != nil { - return CommandResult{}, err - } - return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Phase: string(closure.Record.Phase), Summary: "verified distinct-operator drand responses and exported canonical multi-relay evidence", Outputs: map[string]string{"canonical": canonicalPath, "signing_request": requestPath}}, nil -} - func executeOpsPrepareMirrorReceipt(options OpsPrepareMirrorReceiptOptions) (CommandResult, error) { trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ DefinitionPath: options.CeremonyPath, diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 013f2812..cf88fc1a 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -602,10 +602,6 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { options, err := parseOpsPrepareMirrorReceipt(args[1:]) invocation.Command, invocation.Options = CommandOpsPrepareMirrorReceipt, options return invocation, wrapCommandError(err, "ops", "prepare-mirror-receipt") - case "prepare-beacon-evidence": - options, err := parseOpsPrepareBeaconEvidence(args[1:]) - invocation.Command, invocation.Options = CommandOpsPrepareBeaconEvidence, options - return invocation, wrapCommandError(err, "ops", "prepare-beacon-evidence") case "export-signing": options, err := parseOpsExportSigning(args[1:]) invocation.Command, invocation.Options = CommandOpsExportSigning, options @@ -626,32 +622,6 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { } } -func parseOpsPrepareBeaconEvidence(args []string) (OpsPrepareBeaconEvidenceOptions, error) { - var options OpsPrepareBeaconEvidenceOptions - fs := commandFlagSet("ops prepare-beacon-evidence") - addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) - fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local root containing the signed closure and raw relay responses") - fs.StringVar(&options.ClosurePath, "closure", "", "exact coordinator-signed closure record") - fs.StringVar(&options.ClosureSignaturePath, "closure-signature", "", "detached coordinator signature for the closure") - fs.StringVar(&options.ObservationsPath, "observations", "", "strict JSON describing independently operated relay endpoints and retained raw response names") - fs.StringVar(&options.RecordedAt, "recorded-at", "", "evidence preparation time in RFC3339 UTC") - fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for canonical evidence and signing request") - if err := parseFlags(fs, args); err != nil { - return options, err - } - return options, requireValues( - pathValue("--ceremony", options.CeremonyPath), - pathValue("--ceremony-signature", options.CeremonySignaturePath), - pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), - pathValue("--transcript-root", options.TranscriptRoot), - pathValue("--closure", options.ClosurePath), - pathValue("--closure-signature", options.ClosureSignaturePath), - pathValue("--observations", options.ObservationsPath), - value("--recorded-at", options.RecordedAt), - pathValue("--out-dir", options.OutDir), - ) -} - func parseOpsPreparePublicWitnessReceipt(args []string) (OpsPreparePublicWitnessReceiptOptions, error) { var options OpsPreparePublicWitnessReceiptOptions fs := commandFlagSet("ops prepare-public-witness-receipt") diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 9f544db9..0fd96300 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -42,7 +42,6 @@ const ( CommandReleaseReviewV4 Command = "release review-v4" CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" - CommandOpsPrepareBeaconEvidence Command = "ops prepare-beacon-evidence" CommandOpsExportSigning Command = "ops export-signing" CommandOpsPrepareEnrollment Command = "ops prepare-enrollment" CommandOpsPrepareBundle Command = "ops prepare-bundle" @@ -313,18 +312,6 @@ type OpsPreparePublicWitnessReceiptOptions struct { OutDir string } -type OpsPrepareBeaconEvidenceOptions struct { - CeremonyPath string - CeremonySignaturePath string - CoordinatorPublicKeyFile string - TranscriptRoot string - ClosurePath string - ClosureSignaturePath string - ObservationsPath string - RecordedAt string - OutDir string -} - type OpsImportSignatureOptions struct { RecordType string CanonicalPath string diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 5953c6cd..3debf0b8 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -73,7 +73,6 @@ Commands: ops sign Sign your reviewed enrollment or observation offline ops prepare-public-witness-receipt Prepare witnessed closure bytes ops prepare-mirror-receipt Authenticate a relay draft for offline signing - ops prepare-beacon-evidence Verify multiple relay responses and prepare evidence ops export-signing Export canonical operational bytes for offline signing ops import-signature Import and verify a raw offline Ed25519 signature ops verify Verify a signed operational record fail-closed @@ -740,8 +739,8 @@ closed final/candidate and final/release directories. Parent must exist. 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 + 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. @@ -751,7 +750,9 @@ For Definition V4, replace --candidate-bundle, audit and replay flags with: 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 without replaying contributions. The approved executable is checked +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. `, @@ -824,8 +825,10 @@ proof that the reported real-world actions happened. 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 \ @@ -855,22 +858,6 @@ Authenticates the exact accepted chain prefix and the mirror operator's signed proof-of-possession enrollment, recomputes every receipt file reference, and requires the relay draft to match. It then exports canonical.json and signing-request.json without reading a private signing key. -`, - "ops prepare-beacon-evidence": `Usage: - mpc-ceremony ops prepare-beacon-evidence \ - --ceremony FILE --ceremony-signature FILE \ - --coordinator-public-key-file KEY --transcript-root DIR \ - --closure FILE --closure-signature FILE \ - --observations FILE --recorded-at RFC3339_UTC \ - --out-dir FRESH_DIR - -Authenticates the signed closure, then reads a strict observation-input file. -Each observation names a relay ID, operator ID, HTTPS endpoint, retrieval time, -and raw response path relative to the transcript root. At least two distinct -operators and endpoints are required. The command verifies every response's -drand signature and round, requires identical randomness, hashes endpoint -identities, and exports canonical.json plus signing-request.json. It performs -no network access and does not read a private signing key. `, "ops prepare-enrollment": `Usage: mpc-ceremony ops prepare-enrollment --ceremony FILE --ceremony-signature FILE \ diff --git a/docs/ceremony-schema-compatibility.md b/docs/ceremony-schema-compatibility.md index 35574058..87b3cdf4 100644 --- a/docs/ceremony-schema-compatibility.md +++ b/docs/ceremony-schema-compatibility.md @@ -74,6 +74,10 @@ investigation, and accepted/rejected/retired attempts remain in bounded history. - 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 diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index 8cf2b715..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,13 @@ 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 diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 0144622e..c2103f53 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -19,7 +19,6 @@ const ( CheckpointEnrollmentRecorded CheckpointTransitionKind = "enrollment-recorded" CheckpointMirrorRecorded CheckpointTransitionKind = "mirror-recorded" CheckpointWitnessRecorded CheckpointTransitionKind = "witness-recorded" - CheckpointBeaconEvidenceRecorded CheckpointTransitionKind = "beacon-evidence-recorded" CheckpointAuditRecorded CheckpointTransitionKind = "audit-recorded" CheckpointReleaseReviewRecorded CheckpointTransitionKind = "release-review-recorded" CheckpointIncidentRecorded CheckpointTransitionKind = "incident-recorded" @@ -341,10 +340,6 @@ func (t CheckpointTransitionV4) Validate() error { if len(t.Evidence) != 0 { return errors.New("assurance evidence edge adds only its signed record") } - case CheckpointBeaconEvidenceRecorded: - if len(t.Evidence) < 2 || len(t.Evidence) > 16 { - return errors.New("beacon evidence edge requires two to sixteen raw responses") - } case CheckpointPhase1Closed, CheckpointPhase2Closed: if len(t.Evidence) != 0 { return errors.New("closure transition only adds the signed closure") @@ -519,7 +514,7 @@ func ValidateCheckpointTransitionV4(previous, next CheckpointV4) error { } return nil } - if t.Kind == CheckpointEnrollmentRecorded || t.Kind == CheckpointMirrorRecorded || t.Kind == CheckpointWitnessRecorded || t.Kind == CheckpointBeaconEvidenceRecorded || t.Kind == CheckpointAuditRecorded { + 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") } diff --git a/internal/mpcceremony/checkpoint_v4_beacon_evidence.go b/internal/mpcceremony/checkpoint_v4_beacon_evidence.go index ad771548..93945a14 100644 --- a/internal/mpcceremony/checkpoint_v4_beacon_evidence.go +++ b/internal/mpcceremony/checkpoint_v4_beacon_evidence.go @@ -2,7 +2,6 @@ package mpcceremony import ( "errors" - "slices" ) func checkpointClosureV4(reader *checkpointReaderV4, d CeremonyDefinition, p CheckpointProgressV4, phase Phase) (CloseRecord, []byte, ArtifactRef, error) { @@ -73,56 +72,3 @@ func verifyCheckpointWitnessesV4(reader *checkpointReaderV4, d CeremonyDefinitio } return counts, nil } - -func verifyCheckpointBeaconEvidenceV4(reader *checkpointReaderV4, d CeremonyDefinition, p CheckpointProgressV4, refs []SignedArtifactRefs, tx CheckpointTransitionV4) (map[Phase]bool, error) { - result := map[Phase]bool{} - key, err := identityPublicKey(d.Coordinator) - if err != nil { - return nil, err - } - for _, pair := range refs { - rb, sb, err := reader.pair(pair) - if err != nil { - return nil, err - } - var evidence MultiRelayBeaconEvidence - if err = VerifySignedRecord(rb, sb, &evidence, d.Coordinator.KeyID, key); err != nil { - return nil, err - } - closure, _, _, err := checkpointClosureV4(reader, d, p, evidence.Phase) - if err != nil { - return nil, err - } - if result[evidence.Phase] { - return nil, errors.New("duplicate beacon evidence for phase") - } - beaconRefs := p.Phase1Beacon - if evidence.Phase == Phase2 { - beaconRefs = p.Phase2Beacon - } - if beaconRefs == nil { - return nil, errors.New("beacon evidence requires the recorded phase beacon") - } - raw := map[string][]byte{} - expected := []ArtifactRef{} - for _, observation := range evidence.Observations { - b, err := reader.read(observation.RawResponse, maxDrandResponseBytes, true) - if err != nil { - return nil, err - } - raw[observation.RelayID] = b - expected = append(expected, observation.RawResponse) - } - if err = ValidateMultiRelayBeaconEvidence(d, closure, evidence, raw); err != nil { - return nil, err - } - if tx.Kind == CheckpointBeaconEvidenceRecorded && tx.Record != nil && pair == *tx.Record { - slices.SortFunc(expected, compareArtifactRefName) - if !slices.Equal(expected, tx.Evidence) { - return nil, errors.New("beacon evidence edge differs from its signed raw responses") - } - } - result[evidence.Phase] = true - } - return result, nil -} diff --git a/internal/mpcceremony/checkpoint_v4_bundle.go b/internal/mpcceremony/checkpoint_v4_bundle.go index 7a874a93..84df7b66 100644 --- a/internal/mpcceremony/checkpoint_v4_bundle.go +++ b/internal/mpcceremony/checkpoint_v4_bundle.go @@ -97,9 +97,6 @@ func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCerem if _, err = verifyCheckpointWitnessesV4(reader, d, p, enrollments, a.witnesses); err != nil { return OperationalEvidenceBundle{}, err } - if _, err = verifyCheckpointBeaconEvidenceV4(reader, d, p, a.beaconEvidence, CheckpointTransitionV4{}); err != nil { - return OperationalEvidenceBundle{}, err - } read := func(refs SignedArtifactRefs, out any) error { rb, _, err := reader.pair(refs) if err != nil { @@ -110,7 +107,7 @@ func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCerem witnesses := map[Phase][]SignedArtifactRefs{} mirrors := map[Phase]map[uint8][]SignedArtifactRefs{Phase1: {}, Phase2: {}} beacons := map[Phase]SignedArtifactRefs{} - raws := map[Phase][]ArtifactRef{} + raws := map[Phase]ArtifactRef{} for _, refs := range a.witnesses { var r PublicWitnessReceipt if err = read(refs, &r); err != nil { @@ -125,15 +122,19 @@ func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCerem } mirrors[r.Phase][r.Index] = append(mirrors[r.Phase][r.Index], refs) } - for _, refs := range a.beaconEvidence { - var r MultiRelayBeaconEvidence - if err = read(refs, &r); err != nil { + 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 } - beacons[r.Phase] = refs - for _, ob := range r.Observations { - raws[r.Phase] = append(raws[r.Phase], ob.RawResponse) + 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 @@ -165,8 +166,7 @@ func deriveOperationalBundleV4(reader *checkpointReaderV4, trusted *TrustedCerem 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]), MultiRelayBeaconEvidence: beacons[phase], RawBeaconResponses: append([]ArtifactRef{}, raws[phase]...)} - slices.SortFunc(pe.RawBeaconResponses, func(a, b ArtifactRef) int { return strings.Compare(a.Name, b.Name) }) + 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] diff --git a/internal/mpcceremony/checkpoint_v4_commitments.go b/internal/mpcceremony/checkpoint_v4_commitments.go index e03e8382..950da5db 100644 --- a/internal/mpcceremony/checkpoint_v4_commitments.go +++ b/internal/mpcceremony/checkpoint_v4_commitments.go @@ -12,15 +12,9 @@ import ( type CheckpointCommitmentsV4 struct { Enrollments []SignedArtifactRefs `json:"enrollments"` Turns []TurnCommitmentV4 `json:"turns"` - BeaconEvidence []PhaseCommitmentV4 `json:"beacon_evidence"` FinalReleaseArtifacts []ArtifactRef `json:"final_release_artifacts"` } -type PhaseCommitmentV4 struct { - Phase Phase `json:"phase"` - Pair SignedArtifactRefs `json:"pair"` -} - type CandidateAllocationV4 struct { CheckpointSequence uint64 `json:"checkpoint_sequence"` Checkpoint SignedArtifactRefs `json:"checkpoint"` @@ -87,9 +81,6 @@ func InspectStoredCheckpointV4(trust TrustPaths, root string, head SignedArtifac } defer func() { _ = c.reader.root.Close() }() index, err := checkpointCommitmentsV4(c.ancestry) - if err == nil { - index.BeaconEvidence, err = beaconEvidenceCommitmentsV4(c.reader, c.trusted.Definition, c.ancestry) - } if err == nil { index.FinalReleaseArtifacts, err = finalReleaseDownloadArtifactsV4(c.reader, c.ancestry) } @@ -100,7 +91,7 @@ func checkpointCommitmentsV4(a checkpointAncestryV4) (CheckpointCommitmentsV4, e if len(a.enrollments) > 128 { return CheckpointCommitmentsV4{}, errors.New("enrollment commitment index exceeds protocol capacity") } - index := CheckpointCommitmentsV4{Enrollments: sortedSignedRefsV4(a.enrollments), Turns: []TurnCommitmentV4{}, BeaconEvidence: []PhaseCommitmentV4{}, FinalReleaseArtifacts: []ArtifactRef{}} + index := CheckpointCommitmentsV4{Enrollments: sortedSignedRefsV4(a.enrollments), Turns: []TurnCommitmentV4{}, FinalReleaseArtifacts: []ArtifactRef{}} for _, turn := range a.turnCommitments { index.Turns = append(index.Turns, *turn) } @@ -112,29 +103,3 @@ func checkpointCommitmentsV4(a checkpointAncestryV4) (CheckpointCommitmentsV4, e }) return index, nil } - -func beaconEvidenceCommitmentsV4(reader *checkpointReaderV4, definition CeremonyDefinition, ancestry checkpointAncestryV4) ([]PhaseCommitmentV4, error) { - key, err := identityPublicKey(definition.Coordinator) - if err != nil { - return nil, err - } - result := make([]PhaseCommitmentV4, 0, len(ancestry.beaconEvidence)) - for _, pair := range ancestry.beaconEvidence { - record, signature, err := reader.pair(pair) - if err != nil { - return nil, err - } - var evidence MultiRelayBeaconEvidence - if err := VerifySignedRecord(record, signature, &evidence, definition.Coordinator.KeyID, key); err != nil { - return nil, err - } - result = append(result, PhaseCommitmentV4{Phase: evidence.Phase, Pair: pair}) - } - slices.SortFunc(result, func(a, b PhaseCommitmentV4) int { return strings.Compare(string(a.Phase), string(b.Phase)) }) - for i := 1; i < len(result); i++ { - if result[i-1].Phase == result[i].Phase { - return nil, errors.New("duplicate beacon evidence commitment for phase") - } - } - return result, nil -} diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 45fc1e07..9a4e6ed1 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -141,7 +141,6 @@ type checkpointAncestryV4 struct { enrollmentTransitions []CheckpointTransitionV4 mirrors []SignedArtifactRefs witnesses []SignedArtifactRefs - beaconEvidence []SignedArtifactRefs audits []SignedArtifactRefs incidents []CheckpointTransitionV4 accepted map[ContributionScope]SignedArtifactRefs @@ -202,9 +201,6 @@ func loadCheckpointAncestryV4(reader *checkpointReaderV4, d CeremonyDefinition, if current.Transition.Kind == CheckpointWitnessRecorded { result.witnesses = append(result.witnesses, *current.Transition.Record) } - if current.Transition.Kind == CheckpointBeaconEvidenceRecorded { - result.beaconEvidence = append(result.beaconEvidence, *current.Transition.Record) - } if current.Transition.Kind == CheckpointMirrorRecorded { result.mirrors = append(result.mirrors, *current.Transition.Record) } @@ -450,24 +446,16 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { return nil, errors.New("participant enrollment must be committed before candidate allocation") } } - if c.Transition.Kind == CheckpointWitnessRecorded || c.Transition.Kind == CheckpointBeaconEvidenceRecorded || c.Transition.Kind == CheckpointPhase1Sealed || c.Transition.Kind == CheckpointFinalCandidateRecorded { + if c.Transition.Kind == CheckpointWitnessRecorded || c.Transition.Kind == CheckpointPhase1Sealed || c.Transition.Kind == CheckpointFinalCandidateRecorded { witnessRefs := append([]SignedArtifactRefs{}, evidenceAncestry.witnesses...) - beaconRefs := append([]SignedArtifactRefs{}, evidenceAncestry.beaconEvidence...) if c.Transition.Kind == CheckpointWitnessRecorded { witnessRefs = append(witnessRefs, *c.Transition.Record) } - if c.Transition.Kind == CheckpointBeaconEvidenceRecorded { - beaconRefs = append(beaconRefs, *c.Transition.Record) - } witnessCounts, err := verifyCheckpointWitnessesV4(reader, d, previous.Progress, verifiedEnrollments, witnessRefs) if err != nil { return nil, err } - beacons, err := verifyCheckpointBeaconEvidenceV4(reader, d, previous.Progress, beaconRefs, c.Transition) - if err != nil { - return nil, err - } - if c.Transition.Kind == CheckpointWitnessRecorded || c.Transition.Kind == CheckpointBeaconEvidenceRecorded { + if c.Transition.Kind == CheckpointWitnessRecorded { return MarshalCanonical(c) } phase := Phase1 @@ -477,9 +465,6 @@ func PrepareCheckpointV4(options CheckpointPreparationV4) ([]byte, error) { if witnessCounts[phase] < int(d.AssurancePolicy.PublicWitnessesPerPhase) { return nil, errors.New("signed witness minimum is not satisfied for this phase") } - if !beacons[phase] { - return nil, errors.New("verified multi-relay beacon evidence is required for this phase") - } } if err := verifyCheckpointEvidenceV4(options, trusted, reader, previous, allocations); err != nil { return nil, err diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index ee447c00..6f8f3e70 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -37,7 +37,6 @@ func TestCheckpointV4RealContributionTurn(t *testing.T) { {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"}, - {name: "missing-beacon-evidence", mirrorMode: "0", extra: "MPC_WORKFLOW_SKIP_BEACON_EVIDENCE=1", rejection: "multi-relay beacon evidence is required"}, } { t.Run(scenario.name, func(t *testing.T) { outputRoot := filepath.Join(t.TempDir(), "ceremony-run") diff --git a/internal/mpcceremony/checkpoint_v4_record.go b/internal/mpcceremony/checkpoint_v4_record.go index e303ad4b..5af35dca 100644 --- a/internal/mpcceremony/checkpoint_v4_record.go +++ b/internal/mpcceremony/checkpoint_v4_record.go @@ -26,7 +26,7 @@ type RecordedCheckpointV4 struct { func recordableCheckpointKindV4(kind CheckpointTransitionKind) bool { switch kind { case CheckpointEnrollmentRecorded, CheckpointMirrorRecorded, CheckpointWitnessRecorded, - CheckpointBeaconEvidenceRecorded, CheckpointAuditRecorded, CheckpointIncidentRecorded, + CheckpointAuditRecorded, CheckpointIncidentRecorded, CheckpointPhase1Closed, CheckpointPhase1BeaconRecorded, CheckpointPhase1Sealed, CheckpointPhase2Initialized, CheckpointPhase2Closed, CheckpointPhase2BeaconRecorded, CheckpointFinalCandidateRecorded, CheckpointReleaseReviewRecorded, CheckpointFinalReleaseRecorded, CheckpointAborted: diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index e267420f..f578839b 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -93,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(custodyRequired bool) error { +func (p PhaseOperationalEvidence) validate(custodyRequired, singleBeacon bool) error { if err := p.Phase.Validate(); err != nil { return err } @@ -128,8 +129,23 @@ func (p PhaseOperationalEvidence) validate(custodyRequired bool) 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 @@ -137,14 +153,14 @@ func (p PhaseOperationalEvidence) validate(custodyRequired bool) error { return nil } -func (p PhaseOperationalEvidence) Validate() error { return p.validate(true) } +func (p PhaseOperationalEvidence) Validate() error { return p.validate(true, false) } -// 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. Definition V4 verifies -// historical payload references through signed records, not payload bytes; -// its final review separately requires the coordinator's full-replay claim. +// 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"` @@ -202,13 +218,14 @@ func (b OperationalEvidenceBundle) Validate() error { } } custodyRequired := b.Schema != OperationalEvidenceBundleSchemaV4 - if err := b.Phase1.validate(custodyRequired); err != nil { + 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(custodyRequired); err != nil { + if err := b.Phase2.validate(custodyRequired, singleBeacon); err != nil { return fmt.Errorf("phase2: %w", err) } if b.Phase2.Phase != Phase2 { @@ -392,7 +409,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase1, options.Phase1Close, - enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), + enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), bundle.Schema == OperationalEvidenceBundleSchemaV4, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase1 operational evidence: %w", err) @@ -403,7 +420,7 @@ func verifyOperationalEvidenceContents(options VerifyOperationalEvidenceOptions, options.EvidenceRoot, bundle.Phase2, options.Phase2Close, - enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), + enrollments, expectedAssurance, !options.Definition.UsesSignedAssurancePolicy(), bundle.Schema == OperationalEvidenceBundleSchemaV4, ) if err != nil { return VerifiedOperationalEvidence{}, fmt.Errorf("phase2 operational evidence: %w", err) @@ -552,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() { @@ -580,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 @@ -731,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, diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 7c524c3e..1578d2e6 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -461,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/testdata/workflowhelper/checkpoint_v4.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go index 1437c9ed..11c81db8 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4.go @@ -403,33 +403,6 @@ func runCheckpointV4Turn(output, root string, trust m.TrustPaths, circuit *m.Com if err = commit(); err != nil { return err } - beaconEvidence := m.MultiRelayBeaconEvidence{Schema: m.MultiRelayBeaconEvidenceSchema, CeremonyID: d.CeremonyID, Phase: m.Phase1, CloseID: closure.CloseID, BeaconRound: 42, Provider: d.BeaconPolicy.Provider, Network: d.BeaconPolicy.Network, CoordinatorID: d.Coordinator.ID, CoordinatorKeyID: d.Coordinator.KeyID, RecordedAt: "2023-08-23T15:11:30Z"} - rawRefs := []m.ArtifactRef{} - for _, id := range []string{"fixture-a", "fixture-b"} { - name := "phase1/beacon-evidence/" + id + ".json" - if err = os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0700); err != nil { - return err - } - if err = os.WriteFile(filepath.Join(root, name), []byte(quicknetRound42), 0600); err != nil { - return err - } - rr, err := ref(name) - if err != nil { - return err - } - rawRefs = append(rawRefs, rr) - beaconEvidence.Observations = append(beaconEvidence.Observations, m.RelayObservation{RelayID: id, OperatorID: id, EndpointSHA256: m.NewDigest([]byte(id)).SHA256, RawResponse: rr, RetrievedAt: "2023-08-23T15:11:30Z", VerifiedRandomness: beacon.Beacon.RandomnessHex}) - } - beRefs, err := writePair("phase1/beacon-evidence/record", beaconEvidence, d.Coordinator.KeyID, coordinator) - if err != nil { - return err - } - if os.Getenv("MPC_WORKFLOW_SKIP_BEACON_EVIDENCE") != "1" { - next(m.CheckpointTransitionV4{Kind: m.CheckpointBeaconEvidenceRecorded, Record: &beRefs, Evidence: sorted(rawRefs)}) - 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 diff --git a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go index 65c5c4ca..4a4c8e54 100644 --- a/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go +++ b/internal/mpcceremony/testdata/workflowhelper/checkpoint_v4_final.go @@ -219,31 +219,6 @@ func runCheckpointV4Final(output, root string, trust m.TrustPaths, circuit *m.Co if err = commit(); err != nil { return err } - be := m.MultiRelayBeaconEvidence{Schema: m.MultiRelayBeaconEvidenceSchema, CeremonyID: d.CeremonyID, Phase: m.Phase2, CloseID: closure.CloseID, BeaconRound: 43, Provider: d.BeaconPolicy.Provider, Network: d.BeaconPolicy.Network, CoordinatorID: d.Coordinator.ID, CoordinatorKeyID: d.Coordinator.KeyID, RecordedAt: "2023-08-23T15:11:33Z"} - raws := []m.ArtifactRef{} - for _, id := range []string{"fixture-a", "fixture-b"} { - name := "phase2/beacon-evidence/" + id + ".json" - if err = os.MkdirAll(filepath.Dir(filepath.Join(root, name)), 0700); err != nil { - return err - } - if err = os.WriteFile(filepath.Join(root, name), []byte(quicknetRound43), 0600); err != nil { - return err - } - r, err := ref(name) - if err != nil { - return err - } - raws = append(raws, r) - be.Observations = append(be.Observations, m.RelayObservation{RelayID: id, OperatorID: id, EndpointSHA256: m.NewDigest([]byte(id)).SHA256, RawResponse: r, RetrievedAt: "2023-08-23T15:11:33Z", VerifiedRandomness: beacon.Beacon.RandomnessHex}) - } - ber, err := writePair("phase2/beacon-evidence/record", be, d.Coordinator.KeyID, coordinator) - if err != nil { - return err - } - next(m.CheckpointTransitionV4{Kind: m.CheckpointBeaconEvidenceRecorded, Record: &ber, Evidence: sorted(raws)}) - 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") From 260dba95cd5f79fdaf2176cf0767fb97e2aaba68 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 22:20:30 +0900 Subject: [PATCH 43/53] Include circuit in initial V4 checkpoint --- internal/mpcceremony/checkpoint_v4.go | 4 ++-- internal/mpcceremony/checkpoint_v4_initialize.go | 2 +- internal/mpcceremony/checkpoint_v4_release_test.go | 6 +++++- internal/mpcceremony/checkpoint_v4_test.go | 2 +- 4 files changed, 9 insertions(+), 5 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index c2103f53..6030b890 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -221,8 +221,8 @@ func (c CheckpointV4) Validate() error { } } 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) != 5 { - return errors.New("initial checkpoint must contain exactly the signed definition and genesis chain/payload") + 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 diff --git a/internal/mpcceremony/checkpoint_v4_initialize.go b/internal/mpcceremony/checkpoint_v4_initialize.go index efd68d6f..1e3bf41a 100644 --- a/internal/mpcceremony/checkpoint_v4_initialize.go +++ b/internal/mpcceremony/checkpoint_v4_initialize.go @@ -58,7 +58,7 @@ func PrepareInitialCheckpointV4(options InitialCheckpointV4Options) (InitialChec Progress: CheckpointProgressV4{Phase1: CheckpointPhaseState{ Phase: Phase1, HeadRecordID: headID, HeadPayload: headPayload, Chain: chainRefs, }}, - AcceptedArtifacts: appendUniqueSortedArtifactsV4(nil, trusted.DefinitionRefs.Record, trusted.DefinitionRefs.Signature, chainRefs.Record, chainRefs.Signature, headPayload), + AcceptedArtifacts: appendUniqueSortedArtifactsV4(nil, trusted.DefinitionRefs.Record, trusted.DefinitionRefs.Signature, d.Circuit.R1CS, chainRefs.Record, chainRefs.Signature, headPayload), Deliveries: []DeliverySlotV2{}, } canonical, err := PrepareCheckpointV4(CheckpointPreparationV4{ diff --git a/internal/mpcceremony/checkpoint_v4_release_test.go b/internal/mpcceremony/checkpoint_v4_release_test.go index f6568ad6..cb6d905e 100644 --- a/internal/mpcceremony/checkpoint_v4_release_test.go +++ b/internal/mpcceremony/checkpoint_v4_release_test.go @@ -40,7 +40,11 @@ func TestFinalReleaseV4DerivesClosedDownloadInventory(t *testing.T) { if err != nil { t.Fatal(err) } - defer reader.root.Close() + 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) diff --git a/internal/mpcceremony/checkpoint_v4_test.go b/internal/mpcceremony/checkpoint_v4_test.go index 9f2bc407..6307e277 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -36,7 +36,7 @@ func checkpointFixtureV4(t *testing.T) (CeremonyDefinition, CheckpointV4, []byte 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, chain.Record, chain.Signature, d.Phase1Genesis), Deliveries: []DeliverySlotV2{}} + 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) } From 03b3b3d573127f5545673a678dc1dbbb45016d4d Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Wed, 16 Sep 2026 23:37:24 +0900 Subject: [PATCH 44/53] Encode empty V4 evidence lists explicitly --- internal/mpcceremony/checkpoint_v4_test.go | 7 +++++++ internal/mpcceremony/checkpoint_v4_turn.go | 6 ++++++ 2 files changed, 13 insertions(+) diff --git a/internal/mpcceremony/checkpoint_v4_test.go b/internal/mpcceremony/checkpoint_v4_test.go index 6307e277..314458ca 100644 --- a/internal/mpcceremony/checkpoint_v4_test.go +++ b/internal/mpcceremony/checkpoint_v4_test.go @@ -482,3 +482,10 @@ func TestCheckpointV4ExactPredecessorSignatureAndMinimum(t *testing.T) { 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 index 7e2c7a93..3e5705c9 100644 --- a/internal/mpcceremony/checkpoint_v4_turn.go +++ b/internal/mpcceremony/checkpoint_v4_turn.go @@ -245,6 +245,12 @@ func cloneCheckpointForTurnV4(value CheckpointV4) (CheckpointV4, error) { 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) From ac753b39b3202c4436639218c7e1124abe85dc35 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 01:48:03 +0900 Subject: [PATCH 45/53] Fix V4 final release inventory discovery --- internal/mpcceremony/checkpoint_v4_files.go | 2 +- internal/mpcceremony/checkpoint_v4_release.go | 25 ++++- .../mpcceremony/checkpoint_v4_release_test.go | 93 +++++++++++++------ 3 files changed, 90 insertions(+), 30 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 9a4e6ed1..7979f8b9 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -56,7 +56,7 @@ func (r *checkpointReaderV4) read(ref ArtifactRef, limit int64, capture bool) ([ if ref.Digest.Size <= 0 || ref.Digest.Size > limit { return nil, fmt.Errorf("artifact %s exceeds its permitted size", ref.Name) } - if capture && limit > maxSignedRecordBytes { + if capture && limit > maxFinalTranscriptV3Bytes { return nil, errors.New("large artifacts must be streamed, not retained in memory") } name := ref.Name diff --git a/internal/mpcceremony/checkpoint_v4_release.go b/internal/mpcceremony/checkpoint_v4_release.go index 24c78026..ac2a4fcf 100644 --- a/internal/mpcceremony/checkpoint_v4_release.go +++ b/internal/mpcceremony/checkpoint_v4_release.go @@ -112,14 +112,33 @@ func finalReleaseDownloadArtifactsV4(reader *checkpointReaderV4, a checkpointAnc if c.Progress.ReleaseReview == nil { return nil, errors.New("final release lacks its authenticated review") } - record, _, err := reader.pair(*c.Progress.ReleaseReview) + 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 review ReleaseReviewV4 - if err := UnmarshalCanonical(record, &review); err != nil { + 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 } diff --git a/internal/mpcceremony/checkpoint_v4_release_test.go b/internal/mpcceremony/checkpoint_v4_release_test.go index cb6d905e..72616c0d 100644 --- a/internal/mpcceremony/checkpoint_v4_release_test.go +++ b/internal/mpcceremony/checkpoint_v4_release_test.go @@ -16,30 +16,7 @@ func releaseTransitionFixtureV4() CheckpointTransitionV4 { } func TestFinalReleaseV4DerivesClosedDownloadInventory(t *testing.T) { - root := t.TempDir() - reviewCheckpoint := checkpointSigned("checkpoints/review") - required := checkpointArtifacts(checkpointArtifact("ceremony.json", "definition"), checkpointArtifact("final/candidate/candidate.json", "candidate")) - review := ReleaseReviewV4{ - CeremonyID: "sha256:" + strings.Repeat("a", 64), ReviewCheckpoint: reviewCheckpoint, - FinalCandidateCheckpoint: checkpointSigned("checkpoints/candidate"), CandidateArtifacts: []ArtifactRef{required[1]}, - RequiredArtifacts: required, OperationalBundle: checkpointSigned("operational/bundle"), Audits: []SignedArtifactRefs{}, - ReplayVerification: CheckpointReplayVerificationV4{Method: CoordinatorReplayReleaseV1, ToolBinary: NewDigest([]byte("binary"))}, - ReleasedAt: "2026-07-23T16:00:00Z", - } - raw, err := MarshalCanonical(review) - if err != nil { - t.Fatal(err) - } - signature := []byte("review signature") - pair := SignedArtifactRefs{Record: ArtifactRef{Name: "final/review/record.json", Digest: NewDigest(raw)}, Signature: ArtifactRef{Name: "final/review/record.sig", Digest: NewDigest(signature)}} - writeFixtureFile(t, root, pair.Record.Name, raw) - writeFixtureFile(t, root, pair.Signature.Name, signature) - tx := releaseTransitionFixtureV4() - head := CheckpointV4{PreviousCheckpoint: &reviewCheckpoint, Transition: tx, Progress: CheckpointProgressV4{ReleaseReview: &pair, FinalRelease: tx.Record}} - reader, err := openCheckpointReaderV4(root) - if err != nil { - t.Fatal(err) - } + reader, head := finalReleaseDownloadFixtureV4(t) defer func() { if err := reader.root.Close(); err != nil { t.Errorf("close checkpoint reader: %v", err) @@ -50,8 +27,9 @@ func TestFinalReleaseV4DerivesClosedDownloadInventory(t *testing.T) { t.Fatal(err) } want := []string{ - "final/release/candidate.json", "final/release/ceremony.json", "final/release/checksums.sha256", - "final/release/manifest-public-key.hex", "final/release/manifest.json", "final/release/manifest.sig", "final/release/setup-transcript.json", + "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 { @@ -65,6 +43,69 @@ func TestFinalReleaseV4DerivesClosedDownloadInventory(t *testing.T) { } } +// 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 { From f4ce9d0c02b22bac7ecbc37df44f86f43f1d7062 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:52:19 +0900 Subject: [PATCH 46/53] Fix V4 proof-tool regression coverage --- cmd/mpc-ceremony/checkpoint_v4_test.go | 2 +- cmd/mpc-ceremony/evidence_v4_test.go | 10 +++++++--- cmd/mpc-ceremony/secret_boundary_test.go | 5 ++++- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 4218c8af..21b224f7 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -144,7 +144,7 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { if err != nil { t.Fatal(err) } - refs := []m.ArtifactRef{definition.Record, definition.Signature, chainRefs.Record, chainRefs.Signature, payload} + 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{}} diff --git a/cmd/mpc-ceremony/evidence_v4_test.go b/cmd/mpc-ceremony/evidence_v4_test.go index 70173ced..4450d3dd 100644 --- a/cmd/mpc-ceremony/evidence_v4_test.go +++ b/cmd/mpc-ceremony/evidence_v4_test.go @@ -344,10 +344,14 @@ func TestEvidenceV4CommandsOnRealArtifacts(t *testing.T) { if !bytes.Equal(read(finalCandidateCheckpoint), read(publicationCopy)) { t.Fatal("cross-platform publication verification changed checkpoint bytes") } - signingArgs := append([]string{}, publicationArgs...) + // 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")) - signingArgs[len(signingArgs)-3] = filepath.Join(t.TempDir(), "checkpoint.sig") + signingArgs = append(signingArgs, + "--coordinator-signing-key", filepath.Join(runRoot, "identity-keys/coordinator.ed25519.private.hex"), + "--out", filepath.Join(t.TempDir(), "checkpoint.sig")) if b, err := exec.Command(cli, signingArgs...).CombinedOutput(); 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) } 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 { From 8060cfc72eae05019f316311b9562a121faec965 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 02:55:21 +0900 Subject: [PATCH 47/53] Correct V4 cross-platform signing regression --- cmd/mpc-ceremony/evidence_v4_test.go | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/cmd/mpc-ceremony/evidence_v4_test.go b/cmd/mpc-ceremony/evidence_v4_test.go index 4450d3dd..4d19b6ad 100644 --- a/cmd/mpc-ceremony/evidence_v4_test.go +++ b/cmd/mpc-ceremony/evidence_v4_test.go @@ -352,8 +352,17 @@ func TestEvidenceV4CommandsOnRealArtifacts(t *testing.T) { signingArgs = append(signingArgs, "--coordinator-signing-key", filepath.Join(runRoot, "identity-keys/coordinator.ed25519.private.hex"), "--out", filepath.Join(t.TempDir(), "checkpoint.sig")) - if b, err := exec.Command(cli, signingArgs...).CombinedOutput(); 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) + 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 { From 8e73e812238e9b10063682246de6316e487ee192 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:46:16 +0900 Subject: [PATCH 48/53] Add direct V4 candidate rejection checkpoint --- cmd/mpc-ceremony/checkpoint_command.go | 2 +- cmd/mpc-ceremony/checkpoint_v4.go | 43 ++++- cmd/mpc-ceremony/checkpoint_v4_test.go | 52 ++++++ cmd/mpc-ceremony/executor.go | 2 +- cmd/mpc-ceremony/main.go | 2 +- cmd/mpc-ceremony/types.go | 1 + cmd/mpc-ceremony/usage.go | 14 ++ .../mpcceremony/checkpoint_v4_files_test.go | 23 +++ internal/mpcceremony/checkpoint_v4_turn.go | 176 ++++++++++++++++++ 9 files changed, 307 insertions(+), 8 deletions(-) diff --git a/cmd/mpc-ceremony/checkpoint_command.go b/cmd/mpc-ceremony/checkpoint_command.go index 37880ecd..5e6eaa1e 100644 --- a/cmd/mpc-ceremony/checkpoint_command.go +++ b/cmd/mpc-ceremony/checkpoint_command.go @@ -49,7 +49,7 @@ func parseCheckpoint(invocation Invocation, args []string) (Invocation, error) { 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", "verify-stored-v4", "inspect-signed-v4", "inspect-enrollments-v4": + 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]) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index 6516d7c3..9ab18e77 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -61,7 +61,7 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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" { + 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") @@ -76,7 +76,7 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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" { + 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") @@ -88,6 +88,9 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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") @@ -112,13 +115,16 @@ func parseCheckpointV4(action string, args []string) (CheckpointOptionsV4, error 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" { + 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" { @@ -253,7 +259,7 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, } 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 { + if command == CommandCheckpointAllocateV4 || command == CommandCheckpointAcceptCandidateV4 || command == CommandCheckpointRejectCandidateV4 { _, _, refs, err := checkpointSignedBytes(o.ArtifactRoot, o.CheckpointPath, o.CheckpointSignaturePath) if err != nil { return CommandResult{}, err @@ -274,7 +280,7 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, return CommandResult{}, err } canonical, phase, sequence = prepared.Canonical, string(prepared.Scope.Phase), prepared.Checkpoint.Sequence - } else { + } else if command == CommandCheckpointAcceptCandidateV4 { circuit, err := loadCheckpointCircuitV4(o.ArtifactRoot, d) if err != nil { return CommandResult{}, err @@ -284,6 +290,12 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, return CommandResult{}, err } canonical, phase, sequence = prepared.Canonical, string(prepared.Scope.Phase), prepared.Checkpoint.Sequence + } else { + 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 { @@ -299,6 +311,8 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, action := "allocated the exact next candidate turn" if command == CommandCheckpointAcceptCandidateV4 { action = "verified and accepted the exact allocated candidate" + } else if command == 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 } @@ -491,5 +505,24 @@ func validateCheckpointAtomicOutputV4(o CheckpointOptionsV4) error { 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_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 21b224f7..01542979 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -34,6 +34,23 @@ func TestCheckpointV4CircuitClassification(t *testing.T) { } } +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"} { @@ -161,6 +178,41 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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") } + // 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", initialized.Outputs["checkpoint"], "--checkpoint-signature", initialized.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") + 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 != 2 || !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) + } + assertCheckpointExecutableFails(t, executable, append(reject[:len(reject)-2], "--out-dir", filepath.Join(artifactRoot, "checkpoints", "rejected-again")), "no longer active") prepare := append([]string{"--format", "json", "checkpoint", "prepare-v4"}, trustArgs...) prepare = append(prepare, "--proposal", proposalPath, "--out", checkedPath) runCheckpointCommandExecutable(t, executable, prepare) diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index fe3a0831..846c615f 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -125,7 +125,7 @@ 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, CommandCheckpointVerifyStoredV4, CommandCheckpointInspectSignedV4, CommandCheckpointInspectEnrollmentsV4: + 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)) diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 783d97a6..974e63f1 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -271,7 +271,7 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "checkpoint": {"help": {}, "prepare": {}, "sign": {}, "verify": {}, "verify-stored": {}, "prepare-v4": {}, "sign-v4": {}, "allocate-v4": {}, "accept-candidate-v4": {}, "verify-stored-v4": {}, "verify-release-v4": {}, "inspect-signed-v4": {}, "inspect-enrollments-v4": {}}, + "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": {}, "definition-protocol": {}, "enrollment": {}, "help": {}, "participant": {}, }, diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 0fd96300..3b93cd9e 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -74,6 +74,7 @@ const ( 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" diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 3debf0b8..31c04c97 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -350,6 +350,20 @@ 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 \ diff --git a/internal/mpcceremony/checkpoint_v4_files_test.go b/internal/mpcceremony/checkpoint_v4_files_test.go index 6f8f3e70..e7d55f46 100644 --- a/internal/mpcceremony/checkpoint_v4_files_test.go +++ b/internal/mpcceremony/checkpoint_v4_files_test.go @@ -368,3 +368,26 @@ func TestRejectedInventoryV4ChecksExactPrivateBytes(t *testing.T) { 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_turn.go b/internal/mpcceremony/checkpoint_v4_turn.go index 3e5705c9..e2f4a3d1 100644 --- a/internal/mpcceremony/checkpoint_v4_turn.go +++ b/internal/mpcceremony/checkpoint_v4_turn.go @@ -1,11 +1,16 @@ package mpcceremony import ( + "crypto/sha256" "errors" "fmt" + "io" + "os" "path/filepath" "slices" "sort" + + "golang.org/x/crypto/blake2b" ) // CandidateAllocationCheckpointV4Options identifies one fresh coordinator @@ -113,6 +118,177 @@ type AcceptedCandidateCheckpointV4 struct { 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) + if name == "contribution.bin" { + limit = MaxArtifactSize + } else if name == "attestation.sig" || name == "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 From f35e52461b5a6089fd53d07047bc231afa69e216 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 10:53:58 +0900 Subject: [PATCH 49/53] Fix V4 checkpoint lint suggestions --- internal/mpcceremony/checkpoint_v4.go | 22 +++++++++++++--------- internal/mpcceremony/checkpoint_v4_turn.go | 5 +++-- 2 files changed, 16 insertions(+), 11 deletions(-) diff --git a/internal/mpcceremony/checkpoint_v4.go b/internal/mpcceremony/checkpoint_v4.go index 6030b890..74d61c1f 100644 --- a/internal/mpcceremony/checkpoint_v4.go +++ b/internal/mpcceremony/checkpoint_v4.go @@ -269,12 +269,16 @@ func (t CheckpointTransitionV4) Validate() error { if err := validateHex(t.AttemptID, 16); err != nil { return err } - wantPhase := Phase1 - if t.Kind == CheckpointPhase2CandidateAllocated || t.Kind == CheckpointPhase2CandidateAccepted { - wantPhase = Phase2 - } - if t.Kind != CheckpointDeliveryRetired && t.Kind != CheckpointContributionRejected && t.Kind != CheckpointDeliveryReallocated && t.Scope.Phase != wantPhase { - return errors.New("transition kind and phase disagree") + 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 { @@ -307,13 +311,13 @@ func (t CheckpointTransitionV4) Validate() error { } else if t.AllocatedAt != "" { return errors.New("only candidate allocation records allocated_at") } - if allocated { + 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 - } - if t.Kind == CheckpointDeliveryRetired || t.Kind == CheckpointContributionRejected || t.Kind == CheckpointDeliveryReallocated { + 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") } diff --git a/internal/mpcceremony/checkpoint_v4_turn.go b/internal/mpcceremony/checkpoint_v4_turn.go index e2f4a3d1..2cf5a20f 100644 --- a/internal/mpcceremony/checkpoint_v4_turn.go +++ b/internal/mpcceremony/checkpoint_v4_turn.go @@ -232,9 +232,10 @@ func rejectedCandidateInventoryV4(dir string, scope ContributionScope) (Candidat return CandidateInventory{}, errors.New("rejected directory has missing or extra candidate files") } limit := int64(maxSignedRecordBytes) - if name == "contribution.bin" { + switch name { + case "contribution.bin": limit = MaxArtifactSize - } else if name == "attestation.sig" || name == "erasure.sig" { + case "attestation.sig", "erasure.sig": limit = 4096 } ref, err := rejectedCandidateFileRefV4(reader, name, limit) From cd6fe230051c703983a54b775f05b9e392f26603 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:06:33 +0900 Subject: [PATCH 50/53] Classify semantic V4 candidate failures --- cmd/mpc-ceremony/cli_test.go | 37 ++++++++++++++++++ cmd/mpc-ceremony/main.go | 4 ++ internal/mpcceremony/candidate_invalid_v4.go | 39 +++++++++++++++++++ internal/mpcceremony/computation_output_v4.go | 14 +++++-- .../mpcceremony/contribution_inventory_v4.go | 4 +- .../contribution_inventory_v4_test.go | 22 +++++++++++ 6 files changed, 114 insertions(+), 6 deletions(-) create mode 100644 internal/mpcceremony/candidate_invalid_v4.go 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/main.go b/cmd/mpc-ceremony/main.go index 974e63f1..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 { diff --git a/internal/mpcceremony/candidate_invalid_v4.go b/internal/mpcceremony/candidate_invalid_v4.go new file mode 100644 index 00000000..1e0a0035 --- /dev/null +++ b/internal/mpcceremony/candidate_invalid_v4.go @@ -0,0 +1,39 @@ +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) +} diff --git a/internal/mpcceremony/computation_output_v4.go b/internal/mpcceremony/computation_output_v4.go index 35521af9..da5656c0 100644 --- a/internal/mpcceremony/computation_output_v4.go +++ b/internal/mpcceremony/computation_output_v4.go @@ -68,23 +68,29 @@ func inspectComputationOutputV4(r *checkpointReaderV4, d CeremonyDefinition, cha return zero, attestation, err } if err := VerifySignedRecord(record, signature, &attestation, participant.Identity.KeyID, key); err != nil { - return zero, attestation, err + 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, errors.New("candidate attestation differs from the exact expected predecessor and participant") + 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, err + return zero, attestation, candidateInvalid(err) } if err := validateContributionChronology(d, chain, attestation); err != nil { - return zero, attestation, err + 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 { return zero, attestation, err } diff --git a/internal/mpcceremony/contribution_inventory_v4.go b/internal/mpcceremony/contribution_inventory_v4.go index 0369d555..9ff4b711 100644 --- a/internal/mpcceremony/contribution_inventory_v4.go +++ b/internal/mpcceremony/contribution_inventory_v4.go @@ -88,10 +88,10 @@ func inspectContributionInventoryV4(r *checkpointReaderV4, d CeremonyDefinition, } var erasure ErasureAttestation if err := VerifySignedRecord(data["erasure.json"], data["erasure.sig"], &erasure, participant.Identity.KeyID, key); err != nil { - return zero, err + return zero, candidateInvalid(err) } if err := ValidateErasureForContribution(attestation, erasure); err != nil { - return zero, err + 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"])}, diff --git a/internal/mpcceremony/contribution_inventory_v4_test.go b/internal/mpcceremony/contribution_inventory_v4_test.go index 8461dc9f..d04aca54 100644 --- a/internal/mpcceremony/contribution_inventory_v4_test.go +++ b/internal/mpcceremony/contribution_inventory_v4_test.go @@ -161,6 +161,28 @@ func TestContributionInventoryV4RejectsPartialChangedAndUnboundWork(t *testing.T } } +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("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) { From 4419de85e82e63b0cc1ede50cefba11d2dbbc6f4 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:21:57 +0900 Subject: [PATCH 51/53] Fix V4 checkpoint command regression tests --- cmd/mpc-ceremony/checkpoint_v4.go | 16 +++--- cmd/mpc-ceremony/checkpoint_v4_test.go | 70 +++++++++++++------------- 2 files changed, 45 insertions(+), 41 deletions(-) diff --git a/cmd/mpc-ceremony/checkpoint_v4.go b/cmd/mpc-ceremony/checkpoint_v4.go index 9ab18e77..3ed999f2 100644 --- a/cmd/mpc-ceremony/checkpoint_v4.go +++ b/cmd/mpc-ceremony/checkpoint_v4.go @@ -274,13 +274,14 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, var canonical []byte var phase string var sequence uint64 - if command == CommandCheckpointAllocateV4 { + 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 - } else if command == CommandCheckpointAcceptCandidateV4 { + case CommandCheckpointAcceptCandidateV4: circuit, err := loadCheckpointCircuitV4(o.ArtifactRoot, d) if err != nil { return CommandResult{}, err @@ -290,7 +291,7 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, return CommandResult{}, err } canonical, phase, sequence = prepared.Canonical, string(prepared.Scope.Phase), prepared.Checkpoint.Sequence - } else { + 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 @@ -308,10 +309,13 @@ func executeCheckpointV4(command Command, o CheckpointOptionsV4) (CommandResult, if err := writeAtomicOutputDir(o.OutDir, map[string][]byte{"checkpoint.json": canonical, "checkpoint.sig": signatureBytes}); err != nil { return CommandResult{}, err } - action := "allocated the exact next candidate turn" - if command == CommandCheckpointAcceptCandidateV4 { + action := "" + switch command { + case CommandCheckpointAllocateV4: + action = "allocated the exact next candidate turn" + case CommandCheckpointAcceptCandidateV4: action = "verified and accepted the exact allocated candidate" - } else if command == CommandCheckpointRejectCandidateV4 { + 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 diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 01542979..debc6077 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -178,41 +178,6 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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") } - // 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", initialized.Outputs["checkpoint"], "--checkpoint-signature", initialized.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") - 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 != 2 || !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) - } - assertCheckpointExecutableFails(t, executable, append(reject[:len(reject)-2], "--out-dir", filepath.Join(artifactRoot, "checkpoints", "rejected-again")), "no longer active") prepare := append([]string{"--format", "json", "checkpoint", "prepare-v4"}, trustArgs...) prepare = append(prepare, "--proposal", proposalPath, "--out", checkedPath) runCheckpointCommandExecutable(t, executable, prepare) @@ -280,6 +245,41 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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") + 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) + } + assertCheckpointExecutableFails(t, executable, append(reject[:len(reject)-2], "--out-dir", filepath.Join(artifactRoot, "checkpoints", "rejected-again")), "no longer active") // Sign again only after rereading every required byte, not a saved success marker. genesis := filepath.Join(artifactRoot, payload.Name) original := mustReadTestFile(t, genesis) From c98459b635cb4630743b8c7f9098ee47e1315d50 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:42:07 +0900 Subject: [PATCH 52/53] Classify invalid V4 candidate contents --- cmd/mpc-ceremony/checkpoint_v4_test.go | 3 ++ internal/mpcceremony/candidate_invalid_v4.go | 46 +++++++++++++++++++ internal/mpcceremony/checkpoint_v4_files.go | 7 ++- internal/mpcceremony/checkpoint_v4_turn.go | 8 +++- internal/mpcceremony/computation_output_v4.go | 5 +- .../contribution_inventory_v4_test.go | 16 +++++++ internal/mpcceremony/files.go | 16 +++---- internal/mpcceremony/workflow.go | 24 ++++++++-- 8 files changed, 109 insertions(+), 16 deletions(-) diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index debc6077..0fea5b0d 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -257,6 +257,9 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { ) 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", } { diff --git a/internal/mpcceremony/candidate_invalid_v4.go b/internal/mpcceremony/candidate_invalid_v4.go index 1e0a0035..5fef09e1 100644 --- a/internal/mpcceremony/candidate_invalid_v4.go +++ b/internal/mpcceremony/candidate_invalid_v4.go @@ -37,3 +37,49 @@ 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/checkpoint_v4_files.go b/internal/mpcceremony/checkpoint_v4_files.go index 7979f8b9..d56b0f05 100644 --- a/internal/mpcceremony/checkpoint_v4_files.go +++ b/internal/mpcceremony/checkpoint_v4_files.go @@ -110,8 +110,11 @@ func (r *checkpointReaderV4) read(ref ArtifactRef, limit int64, capture bool) ([ return nil, err } actual := Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size} - if actual != ref.Digest || after.Size() != opened.Size() || !after.ModTime().Equal(opened.ModTime()) { - return nil, fmt.Errorf("artifact %s differs from its exact committed bytes", ref.Name) + 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 diff --git a/internal/mpcceremony/checkpoint_v4_turn.go b/internal/mpcceremony/checkpoint_v4_turn.go index 2cf5a20f..73041df2 100644 --- a/internal/mpcceremony/checkpoint_v4_turn.go +++ b/internal/mpcceremony/checkpoint_v4_turn.go @@ -336,7 +336,13 @@ func VerifyAndAcceptAllocatedCandidateV4(options AcceptAllocatedCandidateV4Optio 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))} - accept := AcceptContributionFilesOptions{Trust: options.Trust, Circuit: options.Circuit, Phase: scope.Phase, Transcript: paths, CandidateDir: options.CandidateDir, CoordinatorPrivateKeyPath: options.CoordinatorPrivateKeyPath, AcceptedAt: options.AcceptedAt} + // 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") diff --git a/internal/mpcceremony/computation_output_v4.go b/internal/mpcceremony/computation_output_v4.go index da5656c0..fad3713d 100644 --- a/internal/mpcceremony/computation_output_v4.go +++ b/internal/mpcceremony/computation_output_v4.go @@ -61,7 +61,7 @@ func inspectComputationOutputV4(r *checkpointReaderV4, d CeremonyDefinition, cha } participant, ok := d.ParticipantByID(scope.ParticipantID) if !ok { - return zero, attestation, errors.New("candidate participant is not scheduled") + return zero, attestation, candidateInvalid(errors.New("candidate participant is not scheduled")) } key, err := identityPublicKey(participant.Identity) if err != nil { @@ -92,6 +92,9 @@ func inspectComputationOutputV4(r *checkpointReaderV4, d CeremonyDefinition, cha 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/contribution_inventory_v4_test.go b/internal/mpcceremony/contribution_inventory_v4_test.go index d04aca54..fd42108e 100644 --- a/internal/mpcceremony/contribution_inventory_v4_test.go +++ b/internal/mpcceremony/contribution_inventory_v4_test.go @@ -172,6 +172,22 @@ func TestContributionInventoryV4ClassifiesOnlySemanticCandidateFailures(t *testi } }) + 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 { 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/workflow.go b/internal/mpcceremony/workflow.go index 2e35615f..4e025354 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -1092,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 { @@ -1111,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 @@ -1238,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 @@ -1269,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 @@ -1299,14 +1315,14 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result return result, fmt.Errorf("clone Phase 2 candidate for verification: %w", err) } if err := verifyPhase2Transition(previous, verifyCandidate); err != nil { - return result, fmt.Errorf("verify candidate Phase 2 transition: %w", err) + return result, candidateFailure(fmt.Errorf("verify candidate Phase 2 transition: %w", err)) } } if modelDigest(candidateDigest) != attestation.OutputPayload.Digest { - return result, errors.New("candidate contribution digest does not match attestation") + return result, candidateFailure(errors.New("candidate contribution digest does not match attestation")) } if err := requireChallengeMatchesDigest(candidateChallenge, previousPayload.Digest); err != nil { - return result, err + return result, candidateFailure(err) } attestationRef := ArtifactRef{Name: names.Attestation, Digest: digestBytes(attestationBytes)} From fe4a298f9a01c97b19760afd8d33d251d5d78b56 Mon Sep 17 00:00:00 2001 From: jason <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 17 Sep 2026 11:53:22 +0900 Subject: [PATCH 53/53] Test idempotent V4 rejection replay --- cmd/mpc-ceremony/checkpoint_v4_test.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/cmd/mpc-ceremony/checkpoint_v4_test.go b/cmd/mpc-ceremony/checkpoint_v4_test.go index 0fea5b0d..cac0b319 100644 --- a/cmd/mpc-ceremony/checkpoint_v4_test.go +++ b/cmd/mpc-ceremony/checkpoint_v4_test.go @@ -282,7 +282,16 @@ func TestCheckpointV4CLIInitialPrepareSignInspectAndMutation(t *testing.T) { 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) } - assertCheckpointExecutableFails(t, executable, append(reject[:len(reject)-2], "--out-dir", filepath.Join(artifactRoot, "checkpoints", "rejected-again")), "no longer active") + // 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)