diff --git a/cmd/mpc-ceremony/decision.go b/cmd/mpc-ceremony/decision.go index 4a34fd9..4925e89 100644 --- a/cmd/mpc-ceremony/decision.go +++ b/cmd/mpc-ceremony/decision.go @@ -118,6 +118,7 @@ func decisionCommandResult( Decision: string(decision.Decision), DecisionID: decision.DecisionID, ReleaseID: decision.Release.ReleaseID, + ReleaseManifestSHA256: decision.Release.Manifest.Artifact.Digest.SHA256, CandidateID: decision.Release.CandidateID, SourceCommit: decision.SourceRelease.SourceCommit, SourceSignedTag: decision.SourceRelease.SignedTag, diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index 14b35c4..1ed3178 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -61,10 +61,16 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeClose(mpcceremony.Phase2, invocation.Options.(CloseOptions)) case CommandPhase2Beacon: return executeBeacon(mpcceremony.Phase2, invocation.Options.(BeaconOptions)) + case CommandRehearsalEvidence: + return executeRehearsalEvidence(invocation.Options.(RehearsalEvidenceOptions)) + case CommandOpsPrepareCustody: + return executeCustody(invocation.Options.(CustodyOptions)) case CommandFinalizePrepare: return executePrepareFinalization(invocation.Options.(PrepareFinalizationOptions)) case CommandFinalizeComplete: return executeFinalize(invocation.Options.(FinalizeOptions)) + case CommandReplay: + return executeReplay(invocation.Options.(AuditOptions)) case CommandAudit: return executeAudit(invocation.Options.(AuditOptions)) case CommandReleaseSign: @@ -562,6 +568,26 @@ func executePrepareFinalization(options PrepareFinalizationOptions) (CommandResu }, nil } +func executeReplay(options AuditOptions) (CommandResult, error) { + trust := trustPaths(options.CeremonyPath, options.CeremonySignaturePath, options.CoordinatorPublicKeyFile) + if err := verifyRunningTrust(trust); err != nil { + return CommandResult{}, err + } + paths, err := replayPaths(trust, options.Replay) + if err != nil { + return CommandResult{}, err + } + circuit, err := compileCircuitForCeremony(trust) + if err != nil { + return CommandResult{}, err + } + id, err := mpcceremony.ReplayCandidate(paths, circuit, options.CandidateBundleDir) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: id, Summary: "independently replayed both phases and reproduced final parameters; no audit signed"}, nil +} + func executeAudit(options AuditOptions) (CommandResult, error) { trust := trustPaths( options.CeremonyPath, @@ -684,8 +710,9 @@ func executeReleaseVerify(options ReleaseVerifyOptions) (CommandResult, error) { return CommandResult{}, err } return CommandResult{ - CeremonyID: result.Transcript.CeremonyID, - Summary: "verified the release signature, bundled audits, native keys, Cardano export, and ceremony coherence", + CeremonyID: result.Transcript.CeremonyID, + ReleaseManifestSHA256: result.ManifestSHA256, + Summary: "verified the release signature, bundled audits, native keys, Cardano export, and ceremony coherence", Outputs: map[string]string{ "keys_dir": options.KeysDir, }, diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 7bb9af8..abc21e6 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -39,6 +39,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"finalize", "prepare"}, {"finalize", "complete"}, {"audit"}, + {"replay"}, {"release"}, {"release", "sign"}, {"release", "verify"}, @@ -217,6 +218,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandFinalizePrepare, Options: PrepareFinalizationOptions{}}, {Command: CommandFinalizeComplete, Options: FinalizeOptions{}}, {Command: CommandAudit, Options: AuditOptions{}}, + {Command: CommandReplay, Options: AuditOptions{}}, {Command: CommandReleaseSign, Options: ReleaseSignOptions{}}, {Command: CommandReleaseVerify, Options: ReleaseVerifyOptions{}}, {Command: CommandDecisionPrepare, Options: DecisionPrepareOptions{}}, @@ -257,6 +259,7 @@ func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { {"phase2", "beacon"}, {"finalize"}, {"audit"}, + {"replay"}, {"release", "sign"}, {"release", "verify"}, {"decision", "sign"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index dddad13..7798bfc 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -146,6 +146,7 @@ const redactedCLIValue = "" // messages remain useful, but values supplied by the caller are never echoed. func redactCLIError(message string, args []string) string { safeCommandArguments := identifyCLICommandArguments(args) + markOperationalGrammar(args, safeCommandArguments) candidates := make(map[string]struct{}) for index, arg := range args { if _, safe := safeCommandArguments[index]; safe { @@ -253,7 +254,7 @@ command: topLevel := map[string]struct{}{ "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "rehearsal": {}, - "release": {}, + "release": {}, "replay": {}, } if _, ok := topLevel[args[index]]; !ok { return safe @@ -274,9 +275,10 @@ command: "chain": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, }, "ops": { - "export-signing": {}, "help": {}, "import-signature": {}, + "export-signing": {}, "help": {}, "import-signature": {}, "sign": {}, "prepare-enrollment": {}, "prepare-handoff": {}, "prepare-receipt": {}, "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "prepare-bundle": {}, "verify": {}, }, + "finalize": {"prepare": {}, "complete": {}, "rehearsal-evidence": {}}, "release": {"help": {}, "sign": {}, "verify": {}}, "rehearsal": {"help": {}, "init": {}}, } @@ -338,3 +340,19 @@ func writeParseError(message string, args []string, stdout, stderr io.Writer) in } return 2 } + +// Only fixed operational grammar is public. Unknown values and all paths remain +// redacted, including values following a recognized flag. +func markOperationalGrammar(args []string, safe map[int]struct{}) { + for index, arg := range args { + if arg == "--related-record" || arg == "--record-type" || arg == "--reviewed-sha256" || arg == "--evidence-root" { + safe[index] = struct{}{} + } + if index > 0 && args[index-1] == "--record-type" { + switch arg { + case "handoff", "receipt", "enrollment", "public-witness", "mirror-receipt", "beacon-evidence", "evidence-bundle": + safe[index] = struct{}{} + } + } + } +} diff --git a/cmd/mpc-ceremony/ops_custody.go b/cmd/mpc-ceremony/ops_custody.go new file mode 100644 index 0000000..0a2f7b4 --- /dev/null +++ b/cmd/mpc-ceremony/ops_custody.go @@ -0,0 +1,217 @@ +package main + +import ( + "crypto/sha256" + "errors" + "fmt" + "golang.org/x/crypto/blake2b" + "io" + "os" + "path/filepath" + "proof-tool/internal/mpcceremony" + "time" +) + +type CustodyOptions struct { + CeremonyPath, CeremonySignaturePath, CoordinatorPublicKeyFile string + Root, Chain, ChainSignature, Participant, Direction, Candidate, OutDir string + Handoff, HandoffSignature, SenderPublicKey string + Receipt bool +} + +func parseCustody(args []string, receipt bool) (CustodyOptions, error) { + o := CustodyOptions{Receipt: receipt} + f := commandFlagSet("ops prepare-custody") + addCeremonyTrustFlags(f, &o.CeremonyPath, &o.CeremonySignaturePath, &o.CoordinatorPublicKeyFile) + f.StringVar(&o.Root, "transcript-root", "", "public files at this station") + f.StringVar(&o.OutDir, "out-dir", "", "fresh public signing packet") + if receipt { + f.StringVar(&o.Handoff, "handoff", "", "exact canonical handoff") + f.StringVar(&o.HandoffSignature, "handoff-signature", "", "sender's detached signature") + f.StringVar(&o.SenderPublicKey, "sender-public-key-file", "", "separately trusted sender key") + } else { + f.StringVar(&o.Chain, "chain", "", "authenticated current chain before this turn") + f.StringVar(&o.ChainSignature, "chain-signature", "", "current chain signature") + f.StringVar(&o.Participant, "participant-id", "", "next scheduled participant") + f.StringVar(&o.Direction, "direction", "outbound", "outbound or return") + f.StringVar(&o.Candidate, "candidate-dir", "", "completed public candidate for return handoff") + } + if err := parseFlags(f, args); err != nil { + return o, err + } + if o.CeremonyPath == "" || o.CeremonySignaturePath == "" || o.CoordinatorPublicKeyFile == "" || o.Root == "" || o.OutDir == "" { + return o, errors.New("ceremony trust, transcript root and fresh output directory are required") + } + if receipt && (o.Handoff == "" || o.HandoffSignature == "" || o.SenderPublicKey == "") { + return o, errors.New("receipt requires the handoff, sender signature and trusted sender public key") + } + if !receipt && (o.Chain == "" || o.ChainSignature == "" || o.Participant == "" || (o.Direction != "outbound" && o.Direction != "return") || (o.Direction == "return" && o.Candidate == "")) { + return o, errors.New("handoff requires the current chain, participant, and outbound or return direction; return also requires the candidate") + } + return o, nil +} +func executeCustody(o CustodyOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{DefinitionPath: o.CeremonyPath, DefinitionSignaturePath: o.CeremonySignaturePath, CoordinatorPublicKeyPath: o.CoordinatorPublicKeyFile}) + if err != nil { + return CommandResult{}, err + } + now := time.Now().UTC().Format(time.RFC3339Nano) + var record any + kind := mpcceremony.RecordHandoff + if o.Receipt { + if _, err := executeOpsVerify(OpsVerifyOptions{RecordType: "handoff", RecordPath: o.Handoff, SignaturePath: o.HandoffSignature, CeremonyPath: o.CeremonyPath, CeremonySignaturePath: o.CeremonySignaturePath, CoordinatorPublicKeyFile: o.CoordinatorPublicKeyFile, SignerPublicKeyFile: o.SenderPublicKey}); err != nil { + return CommandResult{}, err + } + raw, err := readRegularOperationalFile(o.Handoff, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + parsed, err := mpcceremony.ParseOperationalRecord(mpcceremony.RecordHandoff, raw) + if err != nil { + return CommandResult{}, err + } + handoff := parsed.(*mpcceremony.TransferHandoff) + for _, ref := range handoff.Files { + if err := checkCustodyFile(o.Root, ref); err != nil { + return CommandResult{}, err + } + } + receipt, err := mpcceremony.NewTransferReceipt(*handoff, raw, mpcceremony.ReceiptReceiver, now) + if err != nil { + return CommandResult{}, err + } + record = receipt + kind = mpcceremony.RecordReceipt + } else { + chain, err := mpcceremony.LoadSignedChain(trusted, mpcceremony.PhaseTranscriptPaths{RootDir: o.Root, ChainPath: o.Chain, ChainSignaturePath: o.ChainSignature}) + if err != nil { + return CommandResult{}, err + } + index := len(chain.Records) + 1 + var policy mpcceremony.PhasePolicy + if chain.Phase == mpcceremony.Phase1 { + policy = trusted.Definition.Phase1Policy + } else { + policy = trusted.Definition.Phase2Policy + } + if index > len(policy.Participants) || policy.Participants[index-1] != o.Participant { + return CommandResult{}, errors.New("participant is not the next signed turn") + } + participant, ok := trusted.Definition.ParticipantByID(o.Participant) + if !ok { + return CommandResult{}, errors.New("participant is not assigned") + } + head, err := chain.HeadRecordID() + if err != nil { + return CommandResult{}, err + } + payload, err := chain.HeadPayload() + if err != nil { + return CommandResult{}, err + } + sender, recipient := trusted.Definition.Coordinator, participant.Identity + files := []mpcceremony.ArtifactRef{payload} + if o.Direction == "outbound" { + if err := checkCustodyFile(o.Root, payload); err != nil { + return CommandResult{}, err + } + } else { + sender, recipient = recipient, sender + raw, err := readRegularOperationalFile(filepath.Join(o.Candidate, "attestation.json"), maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + var att mpcceremony.ContributionAttestation + if err := mpcceremony.UnmarshalCanonical(raw, &att); err != nil { + return CommandResult{}, err + } + if att.CeremonyID != trusted.Definition.CeremonyID || att.Phase != chain.Phase || int(att.Index) != index || att.ParticipantID != o.Participant || att.PreviousAcceptanceID != head { + return CommandResult{}, errors.New("candidate does not match this turn") + } + files = nil + for _, name := range []string{"attestation.json", "attestation.sig", "contribution.bin", "erasure.json", "erasure.sig"} { + path := filepath.Join(o.Candidate, name) + info, err := os.Lstat(path) + if err != nil || !info.Mode().IsRegular() { + return CommandResult{}, errors.New("return candidate must contain regular public files including cleanup acknowledgment") + } + digest, err := custodyDigest(path) + if err != nil { + return CommandResult{}, err + } + files = append(files, mpcceremony.ArtifactRef{Name: fmt.Sprintf("%s/contributions/%04d/%s", chain.Phase, index, name), Digest: digest}) + } + } + handoff, err := mpcceremony.NewTransferHandoff(trusted.Definition, chain.Phase, uint8(index), head, files, sender, recipient, now, time.Now().UTC().Add(time.Hour).Format(time.RFC3339Nano)) + if err != nil { + return CommandResult{}, err + } + record = handoff + } + canonical, err := mpcceremony.MarshalCanonical(record) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(kind, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + path, requestPath, err := writeOperationalSigningExport(o.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: trusted.Definition.CeremonyID, Summary: fmt.Sprintf("Prepared current-time %s; review and sign exact bytes before the next action. File hashes do not prove physical transfer or erasure.", kind), Outputs: map[string]string{"canonical": path, "signing_request": requestPath, "reviewed_sha256": fmt.Sprintf("%x", sha256.Sum256(canonical))}}, nil +} +func checkCustodyFile(root string, ref mpcceremony.ArtifactRef) error { + if err := ref.Validate(); err != nil { + return err + } + path := filepath.Join(root, filepath.FromSlash(ref.Name)) + rel, err := filepath.Rel(root, path) + if err != nil || rel == ".." || len(rel) >= 3 && rel[:3] == "../" { + return errors.New("custody file escapes public root") + } + for current := path; ; current = filepath.Dir(current) { + info, err := os.Lstat(current) + if err != nil { + return err + } + if info.Mode()&os.ModeSymlink != 0 { + return errors.New("custody files cannot traverse symlinks") + } + if current == filepath.Clean(root) { + break + } + if filepath.Dir(current) == current { + return errors.New("invalid custody root") + } + } + digest, err := custodyDigest(path) + if err != nil { + return err + } + if digest != ref.Digest { + return errors.New("retained file differs from handoff digest") + } + return nil +} + +func custodyDigest(path string) (mpcceremony.Digest, error) { + f, err := os.Open(path) + if err != nil { + return mpcceremony.Digest{}, err + } + defer f.Close() + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + return mpcceremony.Digest{}, errors.New("custody payload must be a regular file") + } + sha := sha256.New() + blake, _ := blake2b.New256(nil) + size, err := io.Copy(io.MultiWriter(sha, blake), f) + return mpcceremony.Digest{SHA256: fmt.Sprintf("sha256:%x", sha.Sum(nil)), Blake2b256: fmt.Sprintf("blake2b256:%x", blake.Sum(nil)), Size: size}, err +} diff --git a/cmd/mpc-ceremony/ops_custody_test.go b/cmd/mpc-ceremony/ops_custody_test.go new file mode 100644 index 0000000..d1100a8 --- /dev/null +++ b/cmd/mpc-ceremony/ops_custody_test.go @@ -0,0 +1,84 @@ +package main + +import ( + "crypto/sha256" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "proof-tool/internal/mpcceremony" + "strings" + "testing" + "time" +) + +func TestCustodySigningAndReceiptRequireExactDeliveredBytes(t *testing.T) { + root := t.TempDir() + definition, _, key := decisionSignFixture(t) + trust := writeInspectionTrustFixture(t, root, definition, key) + payload := []byte("public ceremony payload") + ref := mpcceremony.ArtifactRef{Name: "phase1/genesis.bin", Digest: mpcceremony.NewDigest(payload)} + if err := os.MkdirAll(filepath.Join(root, "phase1"), 0700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, filepath.Join(root, ref.Name), payload, 0600) + now := time.Now().UTC() + handoff, err := mpcceremony.NewTransferHandoff(definition, mpcceremony.Phase1, 1, "sha256:"+strings.Repeat("a", 64), []mpcceremony.ArtifactRef{ref}, definition.Coordinator, definition.Roster[0].Identity, now.Add(-time.Second).Format(time.RFC3339Nano), now.Add(time.Hour).Format(time.RFC3339Nano)) + if err != nil { + t.Fatal(err) + } + raw, err := mpcceremony.MarshalCanonical(handoff) + if err != nil { + t.Fatal(err) + } + record := filepath.Join(root, "handoff.json") + signature := filepath.Join(root, "handoff.sig") + writeDecisionTestFile(t, record, raw, 0600) + keyPath := filepath.Join(root, "private.hex") + writeDecisionTestFile(t, keyPath, []byte(hex.EncodeToString(key.Seed())), 0600) + sign, err := parseOpsSign(append(append([]string{}, trust...), "--record-type", "handoff", "--record", record, "--signing-key", keyPath, "--out", signature, "--reviewed", "--reviewed-sha256", fmt.Sprintf("%x", sha256.Sum256(raw)))) + if err != nil { + t.Fatal(err) + } + bad := sign + bad.ReviewedSHA256 = strings.Repeat("0", 64) + if _, err := executeOpsSign(bad); err == nil { + t.Fatal("signed changed reviewed bytes") + } + if _, err := os.Stat(signature); !os.IsNotExist(err) { + t.Fatal("failed review wrote signature") + } + if _, err := executeOpsSign(sign); err != nil { + t.Fatal(err) + } + if _, err := executeOpsSign(sign); err == nil { + t.Fatal("overwrote signature") + } + receipt, err := parseCustody(append(append([]string{}, trust...), "--transcript-root", root, "--handoff", record, "--handoff-signature", signature, "--sender-public-key-file", filepath.Join(root, "coordinator-public-key.hex"), "--out-dir", filepath.Join(root, "receipt")), true) + if err != nil { + t.Fatal(err) + } + result, err := executeCustody(receipt) + if err != nil { + t.Fatal(err) + } + receiptBytes, err := os.ReadFile(result.Outputs["canonical"]) + if err != nil { + t.Fatal(err) + } + var r mpcceremony.TransferReceipt + if err := mpcceremony.UnmarshalCanonical(receiptBytes, &r); err != nil { + t.Fatal(err) + } + if err := mpcceremony.VerifyTransferReceipt(raw, handoff, r); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, filepath.Join(root, ref.Name), []byte("changed"), 0600) + receipt.OutDir = filepath.Join(root, "tampered-receipt") + if _, err := executeCustody(receipt); err == nil { + t.Fatal("receipt acknowledged different delivered bytes") + } + if _, err := os.Stat(receipt.OutDir); !os.IsNotExist(err) { + t.Fatal("failed receipt left a signing packet") + } +} diff --git a/cmd/mpc-ceremony/ops_guided.go b/cmd/mpc-ceremony/ops_guided.go index a94f30b..2a855f8 100644 --- a/cmd/mpc-ceremony/ops_guided.go +++ b/cmd/mpc-ceremony/ops_guided.go @@ -135,8 +135,14 @@ func executeOpsSign(o OpsSignOptions) (CommandResult, error) { return CommandResult{}, errors.New("owner must review the exact record and explicitly supply --reviewed") } kind := mpcceremony.OperationalRecordType(o.RecordType) - if kind != mpcceremony.RecordEnrollment && kind != mpcceremony.RecordPublicWitness && kind != mpcceremony.RecordMirrorReceipt && kind != mpcceremony.RecordEvidenceBundle { - return CommandResult{}, errors.New("ops sign is restricted to enrollment, public-witness, mirror-receipt and fully verified evidence-bundle records") + switch kind { + case mpcceremony.RecordEnrollment, mpcceremony.RecordPublicWitness, mpcceremony.RecordMirrorReceipt: + case mpcceremony.RecordHandoff, mpcceremony.RecordReceipt, mpcceremony.RecordBeaconEvidence, mpcceremony.RecordEvidenceBundle: + if len(o.ReviewedSHA256) != 64 { + return CommandResult{}, errors.New("custody and aggregate evidence signing requires --reviewed-sha256 of the exact reviewed canonical bytes") + } + default: + return CommandResult{}, errors.New("unsupported operational signing record type") } canonical, record, trusted, err := loadBoundOperationalRecord(kind, o.RecordPath, o.CeremonyPath, o.CeremonySignaturePath, o.CoordinatorPublicKeyFile) if err != nil { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 3de3316..a3e046c 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -84,6 +84,10 @@ func parseInvocation(args []string) (Invocation, error) { return parsePhase2(invocation, rest[1:]) case "finalize": return parseFinalize(invocation, rest[1:]) + case "replay": + options, err := parseReplay(rest[1:]) + invocation.Command, invocation.Options = CommandReplay, options + return invocation, wrapCommandError(err, "replay") case "audit": options, err := parseAudit(rest[1:]) invocation.Command, invocation.Options = CommandAudit, options @@ -432,6 +436,10 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)} } switch args[0] { + case "prepare-handoff", "prepare-receipt": + options, err := parseCustody(args[1:], args[0] == "prepare-receipt") + invocation.Command, invocation.Options = CommandOpsPrepareCustody, options + return invocation, wrapCommandError(err, "ops", args[0]) case "prepare-bundle": options, err := parseOpsPrepareBundle(args[1:]) invocation.Command, invocation.Options = CommandOpsPrepareBundle, options @@ -988,6 +996,10 @@ func parseFinalize(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &usageError{message: "missing finalize command", topic: []string{"finalize"}} } switch args[0] { + case "rehearsal-evidence": + options, err := parseRehearsalEvidence(args[1:]) + invocation.Command, invocation.Options = CommandRehearsalEvidence, options + return invocation, wrapCommandError(err, "finalize", "rehearsal-evidence") case "prepare": options, err := parsePrepareFinalization(args[1:]) invocation.Command, invocation.Options = CommandFinalizePrepare, options @@ -1051,6 +1063,21 @@ func parseCompleteFinalization(args []string) (FinalizeOptions, error) { return options, validateReplayOptions(options.Replay) } +func parseReplay(args []string) (AuditOptions, error) { + var options AuditOptions + fs := commandFlagSet("replay") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + addReplayFlags(fs, &options.Replay) + fs.StringVar(&options.CandidateBundleDir, "candidate-bundle", "", "signed candidate or released key directory") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := requireValues(pathValue("--ceremony", options.CeremonyPath), pathValue("--ceremony-signature", options.CeremonySignaturePath), pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), pathValue("--candidate-bundle", options.CandidateBundleDir)); err != nil { + return options, err + } + return options, validateReplayOptions(options.Replay) +} + func parseAudit(args []string) (AuditOptions, error) { var options AuditOptions fs := commandFlagSet("audit") diff --git a/cmd/mpc-ceremony/redaction_test.go b/cmd/mpc-ceremony/redaction_test.go index 626f24f..326fcec 100644 --- a/cmd/mpc-ceremony/redaction_test.go +++ b/cmd/mpc-ceremony/redaction_test.go @@ -75,3 +75,23 @@ func TestWriteDiagnosticRedactsByConstruction(t *testing.T) { t.Fatalf("writeDiagnostic did not mark the redaction: %q", out.String()) } } + +func TestOperationalGrammarStaysReadableWithoutExposingPaths(t *testing.T) { + args := []string{"ops", "sign", "--record-type", "receipt", "--related-record", "/private/handoff.json", "--signing-key", "/private/signing.hex"} + message := "ops sign receipt requires --related-record; open /private/handoff.json /private/signing.hex" + actual := redactCLIError(message, args) + for _, text := range []string{"ops sign receipt", "--related-record"} { + if !strings.Contains(actual, text) { + t.Fatalf("lost public grammar: %s", actual) + } + } + for _, text := range []string{"/private/handoff.json", "/private/signing.hex"} { + if strings.Contains(actual, text) { + t.Fatal("private path was not redacted") + } + } + unknown := redactCLIError("unknown arbitrary-secret", []string{"ops", "arbitrary-secret"}) + if strings.Contains(unknown, "arbitrary-secret") { + t.Fatal("unknown command exposed") + } +} diff --git a/cmd/mpc-ceremony/rehearsal_evidence.go b/cmd/mpc-ceremony/rehearsal_evidence.go new file mode 100644 index 0000000..e32814a --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal_evidence.go @@ -0,0 +1,143 @@ +// Generate a real proof for the repository's tiny rehearsal circuit and public +// golden vector. No application wallet material is accepted by this helper. +package main + +import ( + "bytes" + "encoding/hex" + "errors" + "github.com/consensys/gnark-crypto/ecc" + "github.com/consensys/gnark/backend/groth16" + "github.com/consensys/gnark/frontend" + "golang.org/x/crypto/blake2b" + "math/big" + "os" + "path/filepath" + "proof-tool/internal/circuit/rehearsal" + "proof-tool/internal/mpcceremony" + "proof-tool/internal/prover" +) + +type RehearsalEvidenceOptions struct{ KeysDir, CoordinatorPublicKeyFile, CeremonyID, OutPath string } + +func parseRehearsalEvidence(args []string) (RehearsalEvidenceOptions, error) { + var o RehearsalEvidenceOptions + f := commandFlagSet("finalize rehearsal-evidence") + f.StringVar(&o.KeysDir, "keys-dir", "", "authenticated preliminary final keys") + f.StringVar(&o.CoordinatorPublicKeyFile, "coordinator-public-key-file", "", "separately trusted coordinator key") + f.StringVar(&o.CeremonyID, "ceremony-id", "", "expected signed ceremony ID") + f.StringVar(&o.OutPath, "out", "", "fresh public evidence file") + if err := parseFlags(f, args); err != nil { + return o, err + } + return o, requireValues(pathValue("--keys-dir", o.KeysDir), pathValue("--coordinator-public-key-file", o.CoordinatorPublicKeyFile), value("--ceremony-id", o.CeremonyID), pathValue("--out", o.OutPath)) +} +func executeRehearsalEvidence(o RehearsalEvidenceOptions) (CommandResult, error) { + if err := generateRehearsalEvidence(o); err != nil { + return CommandResult{}, err + } + return CommandResult{CeremonyID: o.CeremonyID, Summary: "Generated and verified a real tiny-circuit proof using public golden inputs; not a production ownership proof", Outputs: map[string]string{"public_evidence": o.OutPath}}, nil +} +func generateRehearsalEvidence(o RehearsalEvidenceOptions) error { + key, err := os.ReadFile(o.CoordinatorPublicKeyFile) + if err != nil { + return err + } + pre, err := mpcceremony.VerifyPreliminaryFinalKeys(o.KeysDir, string(key)) + if err != nil { + return err + } + if pre.CeremonyID != o.CeremonyID { + return errors.New("ceremony mismatch") + } + if pre.Circuit.Constraints != 5 || pre.Circuit.R1CS.Digest.SHA256 != "sha256:1cbaefe7d52545efae5a9033f6fd381b667ec305da58fb84065a79438c5161ab" { + return errors.New("only the exact pinned five-constraint rehearsal circuit is permitted") + } + ccs, err := mpcceremony.ReadR1CSFile(filepath.Join(o.KeysDir, pre.ConstraintSystem.Name), pre.Circuit) + if err != nil { + return err + } + credential, err := hex.DecodeString(mpcceremony.GoldenPublicCredentialHex) + if err != nil { + return err + } + destination, err := hex.DecodeString(mpcceremony.GoldenPublicDestinationHex) + if err != nil { + return err + } + preimage := append([]byte(mpcceremony.DestinationPublicDomain), credential...) + preimage = append(preimage, destination...) + digest := blake2b.Sum256(preimage) + reversed := bytes.Clone(digest[:]) + for l, r := 0, len(reversed)-1; l < r; l, r = l+1, r-1 { + reversed[l], reversed[r] = reversed[r], reversed[l] + } + scalar := new(big.Int).SetBytes(reversed) + scalar.Mod(scalar, ecc.BLS12_381.ScalarField()) + // The released rehearsal circuit proves X^3 = Pub (the older workflow test + // helper uses a different tiny circuit). This fixed public golden scalar is + // a cubic residue. In this field r-1 = 3*q with gcd(3,q)=1, so exponentiating + // by 3^-1 mod q gives a publicly computable satisfying rehearsal witness. + field := ecc.BLS12_381.ScalarField() + q := new(big.Int).Sub(field, big.NewInt(1)) + q.Div(q, big.NewInt(3)) + exponent := new(big.Int).ModInverse(big.NewInt(3), q) + if exponent == nil { + return errors.New("unexpected scalar-field cube subgroup") + } + cubeRoot := new(big.Int).Exp(scalar, exponent, field) + if new(big.Int).Exp(cubeRoot, big.NewInt(3), field).Cmp(scalar) != 0 { + return errors.New("public golden input is not a cubic residue") + } + witness, err := frontend.NewWitness(&rehearsal.Circuit{X: cubeRoot, Pub: scalar}, field) + if err != nil { + return err + } + pk, err := prover.LoadPK(filepath.Join(o.KeysDir, mpcceremony.NativeProvingKeyFile)) + if err != nil { + return err + } + vk, err := prover.LoadVK(filepath.Join(o.KeysDir, mpcceremony.NativeVerifyingKeyFile)) + if err != nil { + return err + } + proof, err := groth16.Prove(ccs.R1CS, pk, witness) + if err != nil { + return err + } + public, err := witness.Public() + if err != nil { + return err + } + if err = groth16.Verify(proof, vk, public); err != nil { + return err + } + cardano, format, err := prover.SerializeCardanoProof(proof) + if err != nil { + return err + } + vkbytes, err := os.ReadFile(filepath.Join(o.KeysDir, mpcceremony.CardanoVKBytesFile)) + if err != nil { + return err + } + evidence := mpcceremony.PublicFinalizationEvidence{Schema: mpcceremony.PublicEvidenceSchema, CeremonyID: o.CeremonyID, Fixture: mpcceremony.PublicEvidenceFixture, CredentialHex: hex.EncodeToString(credential), DestinationHex: hex.EncodeToString(destination), PublicInputDigestHex: hex.EncodeToString(digest[:]), CardanoProofHex: hex.EncodeToString(cardano), CardanoProofFormat: format, CardanoProofRawDigest: mpcceremony.NewDigest(cardano), CardanoVerifyingKey: mpcceremony.ArtifactRef{Name: mpcceremony.CardanoVKBytesFile, Digest: mpcceremony.NewDigest(vkbytes)}} + if err = evidence.Validate(); err != nil { + return err + } + data, err := mpcceremony.MarshalCanonical(evidence) + if err != nil { + return err + } + f, err := os.OpenFile(o.OutPath, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0600) + if err != nil { + return err + } + defer f.Close() + if _, err = f.Write(data); err != nil { + return err + } + if err = f.Sync(); err != nil { + return err + } + return nil +} diff --git a/cmd/mpc-ceremony/replay_test.go b/cmd/mpc-ceremony/replay_test.go new file mode 100644 index 0000000..fb576c9 --- /dev/null +++ b/cmd/mpc-ceremony/replay_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "strings" + "testing" +) + +func TestPublicReplayRequiresEvidenceButNoSigningIdentity(t *testing.T) { + args := []string{"replay", "--ceremony", "ceremony.json", "--ceremony-signature", "ceremony.sig", "--coordinator-public-key-file", "coordinator.pub", "--candidate-bundle", "release", "--transcript-root", "transcript"} + for _, phase := range []string{"phase1", "phase2"} { + for _, artifact := range []string{"chain", "close", "beacon"} { + args = append(args, "--"+phase+"-"+artifact, "record.json", "--"+phase+"-"+artifact+"-signature", "record.sig") + } + } + args = append(args, "--phase1-seal", "seal.json", "--phase1-seal-signature", "seal.sig") + invocation, err := parseInvocation(args) + if err != nil { + t.Fatal(err) + } + if invocation.Command != CommandReplay { + t.Fatal(invocation.Command) + } + options := invocation.Options.(AuditOptions) + if options.AuditorSigningKey != "" || options.AuditorID != "" || options.SignatureOutPath != "" { + t.Fatal("public replay requested signing state") + } + for _, flag := range []string{"--auditor-signing-key", "--auditor-id", "--out", "--audit-signature"} { + if _, err := parseInvocation(append(append([]string{}, args...), flag, "forbidden")); err == nil { + t.Fatalf("accepted %s", flag) + } + } + if _, err := parseInvocation(args[:len(args)-2]); err == nil || !strings.Contains(err.Error(), "phase1-seal-signature") { + t.Fatalf("missing seal signature accepted: %v", err) + } +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index c0623c5..4c0d6b2 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -31,9 +31,12 @@ const ( CommandPhase2Verify Command = "phase2 verify" CommandPhase2Close Command = "phase2 close" CommandPhase2Beacon Command = "phase2 beacon" + CommandOpsPrepareCustody Command = "ops prepare-custody" CommandFinalizePrepare Command = "finalize prepare" + CommandRehearsalEvidence Command = "finalize rehearsal-evidence" CommandFinalizeComplete Command = "finalize complete" CommandAudit Command = "audit" + CommandReplay Command = "replay" CommandReleaseSign Command = "release sign" CommandReleaseVerify Command = "release verify" CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" @@ -416,6 +419,7 @@ type ReplayOptions struct { } type CommandResult struct { + ReleaseManifestSHA256 string `json:"release_manifest_sha256,omitempty"` Schema string `json:"schema"` OK bool `json:"ok"` Command Command `json:"command"` diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index c5a48a3..911b9ed 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -48,6 +48,8 @@ Commands: phase2 beacon Record signed post-closure beacon evidence finalize prepare Replay both phases and publish preliminary final keys finalize complete Verify external public evidence and create candidate + finalize rehearsal-evidence Generate a real proof for the tiny rehearsal circuit + replay Publicly replay both phases without signing audit Independently replay and audit ceremony artifacts release sign Sign an audited release manifest release verify Verify release and ceremony coherence @@ -352,6 +354,14 @@ Records the distinct Phase 2 post-closure beacon evidence used by finalize. "finalize": `Usage: mpc-ceremony finalize prepare [FLAGS] mpc-ceremony finalize complete [FLAGS] +`, + "finalize rehearsal-evidence": `Usage: + mpc-ceremony finalize rehearsal-evidence --keys-dir DIR \ + --coordinator-public-key-file FILE --ceremony-id ID --out FILE + +Authenticates preliminary keys and checks the exact supported tiny circuit. +Generates and verifies a real proof using public golden inputs. Never accepts +wallet material, overwrites evidence, or produces a production ownership proof. `, "finalize prepare": `Usage: mpc-ceremony finalize prepare --ceremony FILE --ceremony-signature FILE \ @@ -376,6 +386,16 @@ Replays both phases again, verifies the canonical external public proof against the replayed final VK, and creates the coordinator-signed but unsigned-for-release candidate. It accepts only the public evidence artifact. Release signing remains a separate post-audit step. +`, + "replay": `Usage: + mpc-ceremony replay --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY [REPLAY EVIDENCE FLAGS] \ + --candidate-bundle DIR +` + replayFlagsHelp + ` +Independently compiles the signed circuit and replays both phases, checking +randomness, final native keys, Cardano export and public proof evidence. +Requires no private key and writes no signed audit. Release signatures and +production approval are checked separately with release verify and decision verify. `, "audit": `Usage: mpc-ceremony audit --ceremony FILE \ @@ -509,20 +529,47 @@ Derives the canonical enrollment from the authenticated definition and the owner's public identity and disclosure. Internal role indices are derived; external witness/mirror indices are assigned through the coordination channel. No private key is read. Share the entire public export with the disclosure. +`, + "ops prepare-handoff": `Usage: + mpc-ceremony ops prepare-handoff --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root DIR \ + --chain FILE --chain-signature FILE --participant-id ID \ + --direction outbound|return [--candidate-dir DIR] --out-dir FRESH_DIR + +Derives the next turn from the signed current chain. Outbound names its input; +return hashes the completed candidate including cleanup acknowledgment. Creates +an unsigned canonical packet with the actual current time and one-hour expiry. +Review and sign before sending. Preserve an existing packet instead of overwriting +it. A late-created handoff cannot replace a missing earlier custody event. +`, + "ops prepare-receipt": `Usage: + mpc-ceremony ops prepare-receipt --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root RECEIVED_FILES_ROOT \ + --handoff FILE --handoff-signature FILE --sender-public-key-file KEY \ + --out-dir FRESH_DIR + +First receive the exact named public files into their logical paths under the +received-files root. Verifies the sender signature and every received file digest, +then prepares a receipt at the actual current time. Review and sign it as the +named recipient. No network transfer or physical-air-gap claim is made. `, "ops sign": `Usage: mpc-ceremony ops sign --record-type TYPE --record CANONICAL_FILE \ --ceremony FILE --ceremony-signature FILE --coordinator-public-key-file KEY \ --signing-key OWN_KEY_FILE --reviewed [--reviewed-sha256 HEX] --out FRESH_SIGNATURE_JSON -Owner signing for enrollment, public-witness, mirror-receipt or evidence-bundle. +Offline owner signing for enrollment, public-witness, mirror-receipt, handoff, +receipt, beacon-evidence and evidence-bundle records. Bundle signing additionally requires --evidence-root DIR and verifies every -referenced operational record before reading the coordinator's signing key. +referenced operational record before reading the coordinator’s signing key. Authenticates the ceremony, canonical record and owner key. Review the exact record and associated disclosure/observations before --reviewed. This signs your claim; it does not independently observe publication or prove independence. Enrollment signing requires its matching disclosure tree beside the record. -The optional reviewed hash binds signing to bytes previously shown by a helper. +The reviewed hash binds signing to bytes previously shown by a helper. It is +required for handoff, receipt, beacon-evidence and evidence-bundle signing. +Run ops verify afterwards; receipts require --related-record and bundles require +--evidence-root. A signature alone does not verify a complete ceremony. `, "ops prepare-bundle": `Usage: mpc-ceremony ops prepare-bundle --ceremony FILE --ceremony-signature FILE \ diff --git a/docs/ceremony-custody-workflow.md b/docs/ceremony-custody-workflow.md new file mode 100644 index 0000000..7e09a00 --- /dev/null +++ b/docs/ceremony-custody-workflow.md @@ -0,0 +1,78 @@ +# Supported custody and tiny-proof commands + +These commands close the gaps found in the September 2026 same-operator rehearsal. +They do not change the signed protocol, waive operational evidence, prove physical +independence, or authorize a production release. + +## Each participant turn + +1. Before computation, the coordinator runs `ops prepare-handoff` with the exact + current signed chain, next participant, `--direction outbound`, and a fresh + output directory. The command derives the next index, predecessor, software, + circuit, input payload and current creation/expiry times. +2. The coordinator reviews `canonical.json`, then runs `ops sign --record-type + handoff --reviewed --reviewed-sha256 HEX` with their own key and a fresh + signature output. The hash is the SHA-256 of the exact reviewed canonical bytes. +3. Transfer the canonical handoff, signature and named public payload to the + participant through the agreed channel. Keep credentials separate. +4. The participant places received payloads at their named relative paths under + their received-files root. `ops prepare-receipt` verifies the sender signature + and hashes every retained file, then records the actual current receipt time. + Review/sign the receipt as `--record-type receipt`, and return its exact bytes + and signature to the coordinator before computing. +5. Verify the receipt with `ops verify --record-type receipt --related-record + ORIGINAL_HANDOFF`, the recipient public key and their receipt signature. +6. Compute using the approved contributor supervisor. Retain the completed public + candidate and cleanup acknowledgment after container removal. +7. Before acceptance, the participant repeats handoff preparation with `--direction + return --candidate-dir DIR`. It binds the same pre-acceptance chain and the five + public candidate files, including cleanup acknowledgment and signatures. +8. The coordinator receives those named public files, prepares and signs the return + receipt, retains both directions of custody evidence, and only then accepts. + +Use a fresh directory per turn and direction. Interrupted packets are retained; +these commands never overwrite them. Handoffs expire after one hour. A missing +historical handoff cannot be repaired by creating or backdating a later receipt. +The final operational verifier still checks complete custody chronology against +accepted contributions; merely signing one record does not establish that result. + +Run `mpc-ceremony help ops prepare-handoff` and `help ops prepare-receipt` for the +complete flags. Receiver preparation performs no network transfer itself and cannot +prove how the files crossed between machines. Signing belongs on the key-owning +station; container network isolation does not physically disconnect its host. + +## Aggregate operational evidence + +`ops sign` also supports canonical `beacon-evidence` and `evidence-bundle` records. +Both require explicit review and the exact reviewed SHA-256. They retain the same +signed-definition, canonical-record and owner-key checks as other record types. +Run `ops verify --record-type evidence-bundle --evidence-root DIR` afterwards; +this performs the full evidence verification and is mandatory before final release. +Unsupported record types, changed reviewed bytes, wrong owner keys and existing +signature outputs fail closed. + +## Tiny rehearsal proof + +After `finalize prepare`, run: + +```sh +mpc-ceremony finalize rehearsal-evidence \ + --keys-dir /work/preliminary \ + --coordinator-public-key-file /trust/coordinator-public-key.hex \ + --ceremony-id EXPECTED_SIGNED_CEREMONY_ID \ + --out /work/public-finalization-evidence.json +``` + +This authenticates the preliminary keys under the separately trusted coordinator +key, requires the exact supported five-constraint rehearsal circuit, and generates +and verifies a real proof using the repository's public golden input. It accepts +no wallet secret inputs. The output feeds `finalize complete`, which independently +replays the ceremony and validates the proof. Production circuits must use their +own compatible public-evidence generation process. + +## Release sequencing + +Publish these commands through the protected proof-tool release workflow. Relay +must then pin that exact reviewed release and its checksums in both role images. +Existing frozen ceremonies retain their previous tool/software/workflow pins. +Source tests or a local binary alone do not activate a new production release. diff --git a/docs/public-replay.md b/docs/public-replay.md new file mode 100644 index 0000000..b6082c9 --- /dev/null +++ b/docs/public-replay.md @@ -0,0 +1,27 @@ +# Unsigned public ceremony replay + +`mpc-ceremony replay` accepts the same ceremony trust, replay evidence, and candidate +paths as `audit`, but no auditor identity, private key, timestamp, or output signature. +It independently compiles the signed circuit, replays both phases, validates the beacon +and seal bindings, and compares final native keys, Cardano export, and public proof +evidence. It calls the same replay/comparison helper as signed audits. It writes no +protocol assertion and does not waive any running-software or signed-policy checks. +Use `mpc-ceremony replay --help` for the complete file flags. + +`release verify` remains a separate check of the signed release and bundled evidence; +`decision verify` checks production approval. Their JSON results include +`release_manifest_sha256` so archive tools can require a GO decision for the exact +release they have verified, not another release from the same ceremony. + +The installed verifier must satisfy the frozen definition's exact software binding. +This new entry point does not authorize newer binaries for old ceremonies. Trust keys +may come from the website publishing the archive when that is the reader's selected +trust source. Passing checks do not prove secret deletion, offline execution, or human +independence, and unsigned replay must never be described as an enrolled signed audit. + +The signed lifecycle helper exercises unsigned replay before signed audits and rejects +a tampered candidate signature. Run `go test ./cmd/mpc-ceremony ./internal/mpcceremony` +with the normal repository vendor preparation. Builds used by the signed workflow need +real Go VCS metadata: use a full checkout if the local Go version cannot stamp linked +Git worktrees. Do not disable software identity verification to accommodate missing +build metadata. diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 01223bc..e061730 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -88,9 +88,10 @@ type VerifyReleaseOptions struct { } type VerifyReleaseResult struct { - Manifest *artifact.KeyManifest - Transcript FinalTranscript - Candidate CandidateMetadata + ManifestSHA256 string + Manifest *artifact.KeyManifest + Transcript FinalTranscript + Candidate CandidateMetadata } // Audit independently replays both phases from explicit immutable paths, @@ -135,27 +136,7 @@ func Audit(options AuditOptions) (*AuditResult, error) { if !options.AuditedAt.After(candidateTime) { return nil, errors.New("audited_at must strictly postdate candidate finalization") } - phase2Seal, err := loadCandidatePhase2Seal(replay.definition, candidate, options.CandidateDir) - if err != nil { - return nil, err - } - if err := ValidateSeal(replay.phase2Close, replay.phase2Beacon, phase2Seal); err != nil { - return nil, fmt.Errorf("candidate phase2 seal: %w", err) - } - replay.phase2Seal = phase2Seal - replayed, err := replayAll(options.Circuit, replay, options.Replay) - if err != nil { - return nil, err - } - if err := compareCandidateToReplay( - options.Circuit, - replay, - replayed.pk, - replayed.vk, - candidate, - options.CandidateDir, - options.AuditedAt, - ); err != nil { + if err := verifyCandidateReplay(options.Circuit, &replay, options.Replay, candidate, options.CandidateDir); err != nil { return nil, err } replayRoot, err := replayRootSHA256(candidate) @@ -197,6 +178,49 @@ func Audit(options AuditOptions) (*AuditResult, error) { return &AuditResult{Record: record, RecordPath: options.OutPath, SignaturePath: options.SignatureOutPath}, nil } +// ReplayCandidate verifies the complete candidate without an enrolled identity, +// a private key, or writing an audit assertion. The supplied circuit must be +// independently compiled by the trusted caller, as with Audit. +func ReplayCandidate(paths ReplayPaths, circuit *CompiledCircuit, candidateDir string) (string, error) { + if circuit == nil || circuit.R1CS == nil { + return "", errors.New("independently compiled circuit is required") + } + replay, err := loadReplay(paths) + if err != nil { + return "", err + } + if err := VerifyRunningSoftwareForMode(replay.definition.Software, replay.definition.Mode); err != nil { + return "", err + } + if err := ValidateCircuitBinding(circuit, replay.definition.Circuit); err != nil { + return "", err + } + candidate, _, err := verifyCandidate(replay.definition, replay.definitionRef, candidateDir) + if err != nil { + return "", err + } + if err := verifyCandidateReplay(circuit, &replay, paths, candidate, candidateDir); err != nil { + return "", err + } + return replay.definition.CeremonyID, nil +} + +func verifyCandidateReplay(circuit *CompiledCircuit, replay *loadedReplay, paths ReplayPaths, candidate CandidateMetadata, dir string) error { + phase2Seal, err := loadCandidatePhase2Seal(replay.definition, candidate, dir) + if err != nil { + return err + } + if err := ValidateSeal(replay.phase2Close, replay.phase2Beacon, phase2Seal); err != nil { + return fmt.Errorf("candidate phase2 seal: %w", err) + } + replay.phase2Seal = phase2Seal + replayed, err := replayAll(circuit, *replay, paths) + if err != nil { + return err + } + return compareCandidateToReplay(circuit, *replay, replayed.pk, replayed.vk, candidate, dir) +} + func compareCandidateToReplay( circuit *CompiledCircuit, replay loadedReplay, @@ -204,7 +228,6 @@ func compareCandidateToReplay( vk groth16.VerifyingKey, candidate CandidateMetadata, dir string, - auditedAt time.Time, ) error { loadedCCS, err := ReadR1CSFile(filepath.Join(dir, candidate.ConstraintSystem.Name), replay.definition.Circuit) if err != nil { @@ -658,7 +681,11 @@ func VerifyRelease(options VerifyReleaseOptions) (*VerifyReleaseResult, error) { ); err != nil { return nil, err } - return &VerifyReleaseResult{Manifest: manifest, Transcript: transcript, Candidate: candidate}, nil + manifestRef, err := artifactRefForFile(keybundle.ManifestFile, filepath.Join(options.KeysDir, keybundle.ManifestFile)) + if err != nil { + return nil, err + } + return &VerifyReleaseResult{Manifest: manifest, Transcript: transcript, Candidate: candidate, ManifestSHA256: manifestRef.Digest.SHA256}, nil } func verifyCandidate( diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index f0b2b7d..2956f21 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -697,6 +697,25 @@ func run(outputRoot, operationalEvidenceHelper string) error { return fmt.Errorf("complete finalization: %w", err) } + if id, err := mpcceremony.ReplayCandidate(replay, circuit, candidateDir); err != nil || id != initialized.Definition.CeremonyID { + return fmt.Errorf("unsigned public replay failed: %s: %v", id, err) + } + + candidateSignature := filepath.Join(candidateDir, mpcceremony.CandidateSignatureFile) + originalSignature, err := os.ReadFile(candidateSignature) + if err != nil { + return err + } + if err := os.WriteFile(candidateSignature, []byte("tampered"), 0600); err != nil { + return err + } + if _, err := mpcceremony.ReplayCandidate(replay, circuit, candidateDir); err == nil { + return errors.New("unsigned replay accepted tampered candidate signature") + } + if err := os.WriteFile(candidateSignature, originalSignature, 0600); err != nil { + return err + } + auditDir := filepath.Join(outputRoot, "audits") if err := os.Mkdir(auditDir, 0o700); err != nil { return err