From c1fd3e6856a46a77758447c1a67a64b8e074a8ff Mon Sep 17 00:00:00 2001 From: Jason Park Date: Thu, 20 Aug 2026 07:47:20 +0000 Subject: [PATCH 1/2] add downloadable tiny ceremony initializer --- .../mpc-ceremony-release-validation.yml | 41 ++- cmd/mpc-ceremony/cli_test.go | 6 + cmd/mpc-ceremony/executor.go | 24 +- cmd/mpc-ceremony/integration_test.go | 2 + cmd/mpc-ceremony/main.go | 6 +- cmd/mpc-ceremony/parse.go | 56 +++- cmd/mpc-ceremony/rehearsal.go | 63 +++++ cmd/mpc-ceremony/rehearsal_test.go | 62 +++++ cmd/mpc-ceremony/types.go | 6 + cmd/mpc-ceremony/usage.go | 20 +- internal/circuit/rehearsal/circuit.go | 73 ++++++ internal/mpcceremony/definition.go | 14 + internal/mpcceremony/model.go | 27 +- internal/mpcceremony/r1cs.go | 106 +++++++- .../mpcceremony/rehearsal_circuit_test.go | 127 +++++++++ internal/mpcrehearsal/config.go | 243 ++++++++++++++++++ scripts/mpc-rehearsal-config/main.go | 233 +---------------- 17 files changed, 855 insertions(+), 254 deletions(-) create mode 100644 cmd/mpc-ceremony/rehearsal.go create mode 100644 cmd/mpc-ceremony/rehearsal_test.go create mode 100644 internal/circuit/rehearsal/circuit.go create mode 100644 internal/mpcceremony/rehearsal_circuit_test.go create mode 100644 internal/mpcrehearsal/config.go diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml index 974f347..d0d0e70 100644 --- a/.github/workflows/mpc-ceremony-release-validation.yml +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -15,7 +15,7 @@ concurrency: env: # Update this only after reviewing the Relay change and rerunning this gate. - RELAY_COMMIT: c0ccd19f884d6cb355372be95dd159405c3bf368 + RELAY_COMMIT: f4e8a560e2cdae49618b76ef655bfed76bb65e26 jobs: rehearsal-reproducibility: @@ -68,6 +68,45 @@ jobs: "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" \ "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + - name: Exercise download-only tiny rehearsal initialization + shell: bash + run: | + set -euo pipefail + ceremony_binary="$RUNNER_TEMP/mpc-rehearsal-a-parent/release/mpc-ceremony" + rehearsal_root="$RUNNER_TEMP/downloadable-tiny-rehearsal" + "$ceremony_binary" rehearsal init \ + --created-at 2026-08-20T06:00:00Z \ + --out-dir "$rehearsal_root" + "$ceremony_binary" --format json inspect definition \ + --ceremony "$rehearsal_root/public/ceremony.json" \ + --ceremony-signature "$rehearsal_root/public/ceremony.sig" \ + --coordinator-public-key-file \ + "$rehearsal_root/public/coordinator-public-key.hex" \ + >"$RUNNER_TEMP/downloadable-tiny-definition.json" + python3 - "$RUNNER_TEMP/downloadable-tiny-definition.json" <<'PY' + import json + import sys + + with open(sys.argv[1], "rb") as handle: + result = json.load(handle) + inspection = result["definition_inspection"] + assert result["ok"] is True + assert inspection["mode"] == "rehearsal" + assert inspection["phase1_participants"] == [ + "participant-01", "participant-02", "participant-03" + ] + PY + test -f "$rehearsal_root/config/environment.json" + test -f "$rehearsal_root/keys/coordinator.ed25519.private.hex" + test "$(stat -c %a "$rehearsal_root/keys/coordinator.ed25519.private.hex")" = 600 + if "$ceremony_binary" rehearsal init \ + --created-at 2026-08-20T06:00:01Z \ + --out-dir "$rehearsal_root"; then + echo "rehearsal initializer overwrote an existing root" >&2 + exit 1 + fi + test -f "$rehearsal_root/public/ceremony.json" + - name: Confirm rehearsal packages are unsigned shell: bash run: | diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 0f30b82..3239e91 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -951,6 +951,12 @@ func TestDiagnosticRedactionRecognizesInspectionAndReceiptCommands(t *testing.T) commandIndex: 0, valueIndex: 3, }, + { + name: "rehearsal initializer", + args: []string{"rehearsal", "init", "--out-dir", "private-rehearsal"}, + commandIndex: 0, + valueIndex: 3, + }, } { t.Run(test.name, func(t *testing.T) { safe := identifyCLICommandArguments(test.args) diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index c6bb654..e4843b1 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -31,6 +31,8 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com switch invocation.Command { case CommandInit: return executeInit(invocation.Options.(InitOptions)) + case CommandRehearsalInit: + return executeRehearsalInit(invocation.Options.(RehearsalInitOptions)) case CommandInspect: return executeInspect(invocation.Options.(InspectOptions)) case CommandPhase1Contribute: @@ -120,7 +122,7 @@ func executeInit(options InitOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := mpcceremony.CompileForKeyVersion(options.KeyVersion) if err != nil { return CommandResult{}, err } @@ -454,7 +456,7 @@ func executeFinalize(options FinalizeOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := compileCircuitForCeremony(trust) if err != nil { return CommandResult{}, err } @@ -503,7 +505,7 @@ func executePrepareFinalization(options PrepareFinalizationOptions) (CommandResu if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := compileCircuitForCeremony(trust) if err != nil { return CommandResult{}, err } @@ -548,7 +550,7 @@ func executeAudit(options AuditOptions) (CommandResult, error) { if err != nil { return CommandResult{}, err } - circuit, err := mpcceremony.CompileDestinationV2() + circuit, err := compileCircuitForCeremony(trust) if err != nil { return CommandResult{}, err } @@ -898,3 +900,17 @@ func executeInspect(options InspectOptions) (CommandResult, error) { Outputs: outputs, }, nil } + +// compileCircuitForCeremony compiles the circuit the signed definition names. +// +// The key version comes from the definition rather than a flag, so an operator +// cannot select a different circuit than the ceremony was created with. An +// unknown or mismatched version fails in CompileForKeyVersion, and the compiled +// binding is compared against the definition again before anything is accepted. +func compileCircuitForCeremony(trust mpcceremony.TrustPaths) (*mpcceremony.CompiledCircuit, error) { + trusted, err := mpcceremony.LoadSignedDefinition(trust) + if err != nil { + return nil, err + } + return mpcceremony.CompileForKeyVersion(trusted.Definition.Circuit.KeyVersion) +} diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index efac782..1e03db8 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -17,6 +17,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { topics := [][]string{ nil, {"init"}, + {"rehearsal"}, + {"rehearsal", "init"}, {"phase1"}, {"phase1", "contribute"}, {"phase1", "attest-erasure"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 1d1c687..edbbd9b 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -240,7 +240,8 @@ func identifyCLICommandArguments(args []string) map[int]struct{} { command: topLevel := map[string]struct{}{ "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, - "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "release": {}, + "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "rehearsal": {}, + "release": {}, } if _, ok := topLevel[args[index]]; !ok { return safe @@ -264,7 +265,8 @@ command: "export-signing": {}, "help": {}, "import-signature": {}, "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "verify": {}, }, - "release": {"help": {}, "sign": {}, "verify": {}}, + "release": {"help": {}, "sign": {}, "verify": {}}, + "rehearsal": {"help": {}, "init": {}}, } allowed, hasSubcommands := subcommands[args[index]] if hasSubcommands && index+1 < len(args) { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index e28d512..c098738 100644 --- a/cmd/mpc-ceremony/parse.go +++ b/cmd/mpc-ceremony/parse.go @@ -16,6 +16,12 @@ import ( const supportedKeyVersion = "ownership-destination-v2" +// rehearsalKeyVersion selects the tiny circuit used to exercise the ceremony at +// a small domain. It is accepted here only alongside --mode rehearsal; the +// signed definition enforces the same rule independently, so this check is +// convenience rather than the control. +const rehearsalKeyVersion = "rehearsal-tiny-v1" + type helpRequest struct { topic []string } @@ -61,6 +67,8 @@ func parseInvocation(args []string) (Invocation, error) { options, err := parseInit(rest[1:]) invocation.Command, invocation.Options = CommandInit, options return invocation, wrapCommandError(err, "init") + case "rehearsal": + return parseRehearsal(invocation, rest[1:]) case "inspect": if len(rest) > 1 && !strings.HasPrefix(rest[1], "-") { return parseInspectSubcommand(invocation, rest[1:]) @@ -91,6 +99,40 @@ func parseInvocation(args []string) (Invocation, error) { } } +func parseRehearsal(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing rehearsal command", topic: []string{"rehearsal"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"rehearsal"}, args[1:]...)} + } + switch args[0] { + case "init": + options, err := parseRehearsalInit(args[1:]) + invocation.Command, invocation.Options = CommandRehearsalInit, options + return invocation, wrapCommandError(err, "rehearsal", "init") + default: + return Invocation{}, &usageError{ + message: fmt.Sprintf("unknown rehearsal command %q", args[0]), + topic: []string{"rehearsal"}, + } + } +} + +func parseRehearsalInit(args []string) (RehearsalInitOptions, error) { + var options RehearsalInitOptions + fs := commandFlagSet("rehearsal init") + fs.StringVar(&options.CreatedAt, "created-at", "", "ceremony creation timestamp in RFC3339") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh rehearsal work directory") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + value("--created-at", options.CreatedAt), + pathValue("--out-dir", options.OutDir), + ) +} + func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, &usageError{message: "missing inspect command", topic: []string{"inspect"}} @@ -605,7 +647,7 @@ func parseInit(args []string) (InitOptions, error) { fs := commandFlagSet("init") fs.StringVar(&options.SessionNonceHex, "session-nonce-hex", "", "optional 32-byte session nonce as hex; generated securely when omitted") fs.StringVar(&options.CreatedAt, "created-at", "", "ceremony creation timestamp in RFC3339") - fs.StringVar(&options.KeyVersion, "key-version", "", "repository key version (ownership-destination-v2 only)") + fs.StringVar(&options.KeyVersion, "key-version", "", "repository key version (ownership-destination-v2, or rehearsal-tiny-v1 with --mode rehearsal)") fs.StringVar(&options.ParticipantsPath, "participants", "", "participant roster JSON path") fs.StringVar(&options.PolicyPath, "policy", "", "ceremony policy JSON path") fs.StringVar(&options.CoordinatorKeyID, "coordinator-key-id", "", "coordinator signing key identifier") @@ -618,8 +660,16 @@ func parseInit(args []string) (InitOptions, error) { if options.Mode != "rehearsal" && options.Mode != "production" { return options, errors.New("--mode must be rehearsal or production") } - if options.KeyVersion != "" && options.KeyVersion != supportedKeyVersion { - return options, fmt.Errorf("--key-version must be %q", supportedKeyVersion) + switch options.KeyVersion { + case "", supportedKeyVersion: + case rehearsalKeyVersion: + if options.Mode != "rehearsal" { + return options, fmt.Errorf( + "--key-version %q requires --mode rehearsal", rehearsalKeyVersion) + } + default: + return options, fmt.Errorf( + "--key-version must be %q or %q", supportedKeyVersion, rehearsalKeyVersion) } if options.SessionNonceHex != "" { raw, err := hex.DecodeString(options.SessionNonceHex) diff --git a/cmd/mpc-ceremony/rehearsal.go b/cmd/mpc-ceremony/rehearsal.go new file mode 100644 index 0000000..3c2fa71 --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal.go @@ -0,0 +1,63 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "path/filepath" + + "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" +) + +const ( + rehearsalParticipantCount = 3 + rehearsalBeaconLeadSeconds = 300 +) + +func executeRehearsalInit(options RehearsalInitOptions) (result CommandResult, err error) { + if err := mpcrehearsal.Generate( + options.OutDir, + rehearsalParticipantCount, + rehearsalBeaconLeadSeconds, + ); err != nil { + return CommandResult{}, err + } + keepRoot := false + defer func() { + if !keepRoot { + err = errors.Join(err, os.RemoveAll(options.OutDir)) + } + }() + + configRoot := filepath.Join(options.OutDir, "config") + keyRoot := filepath.Join(options.OutDir, "keys") + participantsPath := filepath.Join(configRoot, "participants.json") + participants, err := mpcceremony.LoadInitParticipants(participantsPath) + if err != nil { + return CommandResult{}, err + } + result, err = executeInit(InitOptions{ + CreatedAt: options.CreatedAt, + KeyVersion: rehearsalKeyVersion, + ParticipantsPath: participantsPath, + PolicyPath: filepath.Join(configRoot, "policy.json"), + CoordinatorKeyID: participants.Coordinator.KeyID, + CoordinatorSigningKey: filepath.Join(keyRoot, "coordinator.ed25519.private.hex"), + OutDir: filepath.Join(options.OutDir, "public"), + Mode: mpcceremony.ModeRehearsal, + }) + if err != nil { + return CommandResult{}, err + } + result.Command = CommandRehearsalInit + result.Summary = "initialized same-host three-participant rehearsal fixture (NOT PRODUCTION)" + result.Outputs["config_root"] = configRoot + result.Outputs["environment"] = filepath.Join(configRoot, "environment.json") + result.Outputs["key_root"] = keyRoot + result.Outputs["rehearsal_root"] = options.OutDir + keepRoot = true + return result, nil +} diff --git a/cmd/mpc-ceremony/rehearsal_test.go b/cmd/mpc-ceremony/rehearsal_test.go new file mode 100644 index 0000000..163343f --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +func TestParseRehearsalInitIsNarrowAndExplicit(t *testing.T) { + t.Parallel() + + invocation, err := parseInvocation([]string{ + "rehearsal", "init", + "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", + }) + if err != nil { + t.Fatal(err) + } + if invocation.Command != CommandRehearsalInit { + t.Fatalf("command = %q", invocation.Command) + } + options := invocation.Options.(RehearsalInitOptions) + if options.CreatedAt != "2026-08-20T06:00:00Z" || options.OutDir != "/secure/rehearsal" { + t.Fatalf("options = %+v", options) + } + + for name, args := range map[string][]string{ + "missing creation time": {"rehearsal", "init", "--out-dir", "/secure/rehearsal"}, + "missing output": {"rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z"}, + "production mode": { + "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", "--mode", "production", + }, + "production circuit": { + "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", "--key-version", supportedKeyVersion, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + if _, err := parseInvocation(args); err == nil { + t.Fatal("unsafe rehearsal initializer invocation was accepted") + } + }) + } +} + +func TestRehearsalInitHelpLabelsOutputAsNonProduction(t *testing.T) { + t.Parallel() + + var output strings.Builder + if err := writeUsage(&output, []string{"rehearsal", "init"}); err != nil { + t.Fatal(err) + } + lower := strings.ToLower(output.String()) + if !strings.Contains(lower, "rehearsal-tiny-v1") || !strings.Contains(lower, "not production") { + t.Fatalf("help does not state the rehearsal boundary: %q", output.String()) + } +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index 6cbb7f2..1ea3cbd 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -16,6 +16,7 @@ type Command string const ( CommandInit Command = "init" + CommandRehearsalInit Command = "rehearsal init" CommandInspect Command = "inspect" CommandPhase1Contribute Command = "phase1 contribute" CommandPhase1Erasure Command = "phase1 attest-erasure" @@ -71,6 +72,11 @@ type InitOptions struct { Mode string } +type RehearsalInitOptions struct { + CreatedAt string + OutDir string +} + type ContributeOptions struct { CeremonyPath string CeremonySignaturePath string diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index d3400e0..8684621 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -23,11 +23,14 @@ const rootHelp = `Usage: mpc-ceremony [--format human|json] [--quiet] [flags] Offline, append-only orchestration for this repository's BLS12-381 Groth16 -multi-party setup. The binary accepts setup artifacts and signing keys only. -It performs no network access and never selects a mutable "latest" artifact. +multi-party setup. Production commands accept operator-supplied artifacts and +signing keys only; the explicitly rehearsal-only initializer creates same-host +test identities. The binary performs no network access and never selects a +mutable "latest" artifact. Commands: init Bind a ceremony to the compiled repository circuit + rehearsal init Create and initialize a three-party tiny rehearsal inspect Report chain state and next scheduled contribution phase1 contribute Verify the full phase 1 chain and contribute phase1 attest-erasure Sign a participant destruction attestation @@ -105,6 +108,19 @@ second path list. ` var commandHelp = map[string]string{ + "rehearsal": `Usage: + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR + +Rehearsal commands create same-host test identities and must never be used as +production enrollment evidence. +`, + "rehearsal init": `Usage: + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR + +Creates fresh same-host identities and canonical configuration for exactly +three participants, then initializes a signed rehearsal-tiny-v1 ceremony. The +output is a functional test fixture, not production or independence evidence. +`, "inspect": inspectHelp + ` Authenticated record projections are also available as subcommands: mpc-ceremony inspect [flags] diff --git a/internal/circuit/rehearsal/circuit.go b/internal/circuit/rehearsal/circuit.go new file mode 100644 index 0000000..8fbb522 --- /dev/null +++ b/internal/circuit/rehearsal/circuit.go @@ -0,0 +1,73 @@ +// Package rehearsal defines a deliberately tiny circuit used only to exercise +// the MPC ceremony machinery. +// +// The production destination-v2 circuit has roughly 1.79 million constraints, +// which forces an FFT domain of 2^21. That makes every ceremony operation +// expensive: a single contribution moves 604 MiB and takes minutes, a phase +// close replays the whole accepted chain and takes over an hour, and a full +// rehearsal is a multi-day exercise. Testing the orchestration around the +// ceremony at that size is impractical. +// +// This circuit proves a trivial statement at a small domain so the same +// orchestration can be exercised in seconds. It proves nothing useful and must +// never appear in a production ceremony; CeremonyDefinition rejects it whenever +// mode is production, and the K21 rehearsal gate in the production decision +// continues to demand domain 2^21 so a run at this size can never satisfy it. +package rehearsal + +import ( + "errors" + "math/big" + + "github.com/consensys/gnark/frontend" +) + +const ( + // CircuitID names this circuit in a ceremony definition. The "rehearsal" + // prefix is load bearing: it is what a reader sees in ceremony.json, and it + // must be obvious at a glance that a transcript is not production evidence. + CircuitID = "rehearsal-tiny-v1/bls12-381/groth16" + + // KeyVersion is the value passed to init --key-version to select this + // circuit. + KeyVersion = "rehearsal-tiny-v1" +) + +// Circuit proves knowledge of a value whose cube equals the public input. The +// statement is arbitrary; what matters is that it compiles to a handful of +// constraints and therefore a small domain. +type Circuit struct { + X frontend.Variable + Pub frontend.Variable `gnark:",public"` +} + +func (c *Circuit) Define(api frontend.API) error { + cube := api.Mul(api.Mul(c.X, c.X), c.X) + api.AssertIsEqual(cube, c.Pub) + + // Exactly one Groth16 commitment, matching destination-v2. + // + // This is not decoration. Finalization exports a Cardano-format verifying + // key whose BSB22 encoding assumes a single commitment, so a circuit with + // none cannot be finalized at all. Without this the rehearsal circuit could + // exercise the ceremony only as far as the beacon, and the finalize, + // audit and release stages would stay untestable. + committer, ok := api.(frontend.Committer) + if !ok { + return errors.New("rehearsal circuit requires a committer API") + } + commitment, err := committer.Commit(c.X) + if err != nil { + return err + } + api.AssertIsDifferent(commitment, 0) + return nil +} + +// Assignment builds a satisfying witness for the given secret. +func Assignment(x int64) *Circuit { + value := big.NewInt(x) + cube := new(big.Int).Mul(value, value) + cube.Mul(cube, value) + return &Circuit{X: value, Pub: cube} +} diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index 194ea5e..abad5d0 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -137,6 +137,20 @@ func (d CeremonyDefinition) validate(requireID bool) error { switch d.Mode { case ModeRehearsal: case ModeProduction: + // The circuit registry accepts a tiny rehearsal circuit so the ceremony + // machinery can be exercised at a small domain. Production must never + // see it: a transcript at domain 2^16 proves nothing about a 2^21 + // ceremony, and the exact-k21-rehearsal gate exists precisely so a + // smaller run cannot satisfy it. This is the only place that knows the + // mode, so it is the only place the restriction can live, and it is + // decided before any environment-dependent check so the failure is + // about the definition rather than the host. + if d.Circuit.KeyVersion != KeyVersionDestinationV2 { + return fmt.Errorf( + "production ceremony must use key_version %q, not %q", + KeyVersionDestinationV2, d.Circuit.KeyVersion, + ) + } if d.Software.SourceDirty { return errors.New("production ceremony requires a clean source tree") } diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index 4eb29a5..aa15c5c 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -33,6 +33,11 @@ const ( KeyVersionDestinationV2 = "ownership-destination-v2" CircuitIDDestinationV2 = "root-ownership-destination-v2/bls12-381/groth16" + // KeyVersionRehearsal names the tiny circuit used to exercise the ceremony + // machinery at a small domain. It is accepted only when mode is rehearsal; + // see CeremonyDefinition.validate. + KeyVersionRehearsal = "rehearsal-tiny-v1" + CircuitIDRehearsal = "rehearsal-tiny-v1/bls12-381/groth16" CurveBLS12381 = "BLS12-381" BackendGroth16 = "groth16" GnarkVersion = "v0.15.0" @@ -220,11 +225,23 @@ type CircuitBinding struct { } func (b CircuitBinding) Validate() error { - if b.KeyVersion != KeyVersionDestinationV2 { - return fmt.Errorf("key_version %q, want %q", b.KeyVersion, KeyVersionDestinationV2) - } - if b.CircuitID != CircuitIDDestinationV2 { - return fmt.Errorf("circuit_id %q, want %q", b.CircuitID, CircuitIDDestinationV2) + // Key version and circuit id are checked as a pair, not independently. A + // definition naming one circuit's version with another's id would otherwise + // pass both checks separately while describing nothing that exists. + // + // This is membership in a closed set rather than equality with a single + // constant, which is a weaker check than it replaced. What restores the + // strength is that a production definition may only name destination-v2; + // CeremonyDefinition.validate enforces that, and it is the only place that + // knows the mode. + switch { + case b.KeyVersion == KeyVersionDestinationV2 && b.CircuitID == CircuitIDDestinationV2: + case b.KeyVersion == KeyVersionRehearsal && b.CircuitID == CircuitIDRehearsal: + default: + return fmt.Errorf( + "key_version %q with circuit_id %q is not a known circuit", + b.KeyVersion, b.CircuitID, + ) } if b.Curve != CurveBLS12381 { return fmt.Errorf("curve %q, want %q", b.Curve, CurveBLS12381) diff --git a/internal/mpcceremony/r1cs.go b/internal/mpcceremony/r1cs.go index 96f50d0..406e7ef 100644 --- a/internal/mpcceremony/r1cs.go +++ b/internal/mpcceremony/r1cs.go @@ -15,6 +15,11 @@ import ( "github.com/consensys/gnark/backend/groth16" "github.com/consensys/gnark/constraint" bls12381cs "github.com/consensys/gnark/constraint/bls12-381" + "github.com/consensys/gnark/frontend" + r1csbuilder "github.com/consensys/gnark/frontend/cs/r1cs" + + "proof-tool/internal/circuit/rehearsal" + "golang.org/x/crypto/blake2b" "proof-tool/internal/keyprofile" @@ -114,7 +119,11 @@ func ReadR1CSFile(path string, expected CircuitBinding) (*CompiledCircuit, error ); err != nil { return nil, fmt.Errorf("decode frozen R1CS %q: %w", path, err) } - compiled, err := bindDestinationV2R1CS(native) + // Bind using the identity the signed definition names, not a fixed one. + // The result is compared against that same expected binding immediately + // below, so this cannot be used to accept a circuit the definition did not + // ask for: it only decides which rules the file is checked against. + compiled, err := bindForKeyVersion(native, expected.KeyVersion) if err != nil { return nil, fmt.Errorf("validate frozen R1CS %q: %w", path, err) } @@ -157,6 +166,24 @@ func WriteR1CSFileNoReplace(path string, circuit *CompiledCircuit) (Digest, erro } func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircuit, error) { + return bindR1CS(compiled, KeyVersionDestinationV2, CircuitIDDestinationV2, destinationV2CommitmentCount) +} + +// bindR1CS derives the circuit binding for a compiled constraint system. +// +// Identity and expected commitment count are parameters rather than constants +// because the ceremony supports a second, deliberately tiny circuit for +// rehearsals. Every other rule here is unchanged and applies to both: the +// scalar field, the domain, the variable counts and the exact serialized +// digest are checked identically, so a rehearsal transcript is as internally +// consistent as a production one. What separates them is which key version a +// definition may name, which CeremonyDefinition decides using the mode. +func bindR1CS( + compiled constraint.ConstraintSystem, + keyVersion string, + circuitID string, + wantCommitments int, +) (*CompiledCircuit, error) { if compiled == nil { return nil, errors.New("constraint system is required") } @@ -190,11 +217,12 @@ func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircu if err != nil { return nil, err } - if len(commitments) != destinationV2CommitmentCount { + if len(commitments) != wantCommitments { return nil, fmt.Errorf( - "destination-v2 constraint system has %d commitments, want %d", + "%s constraint system has %d commitments, want %d", + keyVersion, len(commitments), - destinationV2CommitmentCount, + wantCommitments, ) } @@ -207,8 +235,8 @@ func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircu return nil, err } binding := CircuitBinding{ - KeyVersion: KeyVersionDestinationV2, - CircuitID: CircuitIDDestinationV2, + KeyVersion: keyVersion, + CircuitID: circuitID, Curve: CurveBLS12381, Backend: BackendGroth16, R1CS: ArtifactRef{Name: prover.DestinationConstraintSystemFile, Digest: digest}, @@ -220,7 +248,7 @@ func bindDestinationV2R1CS(compiled constraint.ConstraintSystem) (*CompiledCircu Phase2Shape: phase2Shape, } if err := binding.Validate(); err != nil { - return nil, fmt.Errorf("derived destination-v2 circuit binding: %w", err) + return nil, fmt.Errorf("derived %s circuit binding: %w", keyVersion, err) } return &CompiledCircuit{R1CS: native, Binding: binding, validated: true}, nil } @@ -446,3 +474,67 @@ func equalPhase2Shape(left, right Phase2Shape) bool { } return true } + +// rehearsalCommitmentCount is the number of Groth16 commitments the rehearsal +// circuit produces. It matches destination-v2 deliberately: finalization +// exports a Cardano verifying key whose BSB22 encoding assumes exactly one +// commitment, so a circuit with a different count cannot be finalized and the +// later ceremony stages would be untestable. +const rehearsalCommitmentCount = destinationV2CommitmentCount + +// CompileForKeyVersion compiles the circuit a ceremony definition names. +// +// This is the one place that maps a key version to a circuit, and it is +// deliberately a closed set rather than a lookup that could be extended by a +// definition. An unknown key version is an error, not a request. +// +// Selecting the rehearsal circuit here does not make a rehearsal ceremony +// acceptable in production: CeremonyDefinition.validate rejects any key version +// other than destination-v2 when mode is production, and the K21 rehearsal gate +// in the production decision continues to require domain 2^21. +func CompileForKeyVersion(keyVersion string) (*CompiledCircuit, error) { + switch keyVersion { + case KeyVersionDestinationV2: + return CompileDestinationV2() + case KeyVersionRehearsal: + return compileRehearsal() + default: + return nil, fmt.Errorf( + "unknown key_version %q: want %q or %q", + keyVersion, KeyVersionDestinationV2, KeyVersionRehearsal, + ) + } +} + +func compileRehearsal() (*CompiledCircuit, error) { + compiled, err := frontend.Compile( + ecc.BLS12_381.ScalarField(), + r1csbuilder.NewBuilder, + &rehearsal.Circuit{}, + ) + if err != nil { + return nil, fmt.Errorf("compile rehearsal circuit: %w", err) + } + return bindR1CS(compiled, KeyVersionRehearsal, CircuitIDRehearsal, rehearsalCommitmentCount) +} + +// bindForKeyVersion applies the binding rules for a named circuit. +// +// Both circuits carry exactly one Groth16 commitment, and every other rule - +// scalar field, domain, variable counts, exact serialized digest - is applied +// identically. That is what makes a rehearsal transcript internally consistent +// in the same way a production one is; the circuits differ in what they prove +// and in the domain they need, not in how they are bound. +func bindForKeyVersion(compiled constraint.ConstraintSystem, keyVersion string) (*CompiledCircuit, error) { + switch keyVersion { + case KeyVersionDestinationV2: + return bindR1CS(compiled, KeyVersionDestinationV2, CircuitIDDestinationV2, destinationV2CommitmentCount) + case KeyVersionRehearsal: + return bindR1CS(compiled, KeyVersionRehearsal, CircuitIDRehearsal, rehearsalCommitmentCount) + default: + return nil, fmt.Errorf( + "unknown key_version %q: want %q or %q", + keyVersion, KeyVersionDestinationV2, KeyVersionRehearsal, + ) + } +} diff --git a/internal/mpcceremony/rehearsal_circuit_test.go b/internal/mpcceremony/rehearsal_circuit_test.go new file mode 100644 index 0000000..9916fa4 --- /dev/null +++ b/internal/mpcceremony/rehearsal_circuit_test.go @@ -0,0 +1,127 @@ +package mpcceremony + +import ( + "strings" + "testing" +) + +// TestCompileForKeyVersionRejectsUnknown keeps the registry a closed set. An +// unknown key version must be an error rather than a request the definition +// gets to make. +func TestCompileForKeyVersionRejectsUnknown(t *testing.T) { + for _, keyVersion := range []string{ + "", "ownership", "ownership-destination-v3", + "rehearsal-tiny-v2", " rehearsal-tiny-v1", + } { + if _, err := CompileForKeyVersion(keyVersion); err == nil { + t.Errorf("CompileForKeyVersion(%q) accepted an unknown circuit", keyVersion) + } + } +} + +func TestRehearsalCircuitCompilesSmall(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatalf("CompileForKeyVersion: %v", err) + } + if circuit.Binding.KeyVersion != KeyVersionRehearsal || + circuit.Binding.CircuitID != CircuitIDRehearsal { + t.Fatalf("binding identity is %+v", circuit.Binding) + } + // The entire point is a small domain. If the rehearsal circuit ever grew to + // production scale it would stop being useful and this test should fail + // rather than quietly cost minutes per contribution. + if circuit.Binding.DomainSize > 1<<12 { + t.Fatalf("rehearsal domain is %d, expected something tiny", circuit.Binding.DomainSize) + } + if circuit.Binding.Curve != CurveBLS12381 || circuit.Binding.Backend != BackendGroth16 { + t.Fatalf("rehearsal circuit must use the same curve and backend: %+v", circuit.Binding) + } +} + +// TestCircuitBindingChecksIdentityAsAPair guards the weakness introduced by +// moving from equality with one constant to membership in a set: a definition +// naming one circuit's key version with another's circuit id would otherwise +// satisfy two independent checks while describing nothing that exists. +func TestCircuitBindingChecksIdentityAsAPair(t *testing.T) { + base, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + mixed := base.Binding + mixed.CircuitID = CircuitIDDestinationV2 + if err := mixed.Validate(); err == nil { + t.Fatal("Validate accepted a rehearsal key_version with the destination-v2 circuit_id") + } + + swapped := base.Binding + swapped.KeyVersion = KeyVersionDestinationV2 + if err := swapped.Validate(); err == nil { + t.Fatal("Validate accepted a destination-v2 key_version with the rehearsal circuit_id") + } +} + +// TestProductionRejectsRehearsalCircuit is the guard that restores what the +// membership check gave up. A rehearsal transcript proves nothing about a +// production ceremony, and the definition is the only place that knows the mode. +func TestProductionRejectsRehearsalCircuit(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + definition := CeremonyDefinition{ + Schema: DefinitionSchema, + Mode: ModeProduction, + Circuit: circuit.Binding, + } + err = definition.validate(false) + if err == nil { + t.Fatal("a production definition accepted the rehearsal circuit") + } + if !strings.Contains(err.Error(), KeyVersionDestinationV2) { + t.Fatalf("error should name the required key version, got: %v", err) + } +} + +// TestRehearsalModeAcceptsRehearsalCircuit confirms the guard is conditional on +// the mode rather than rejecting the circuit outright, which would make the +// whole change pointless. +func TestRehearsalModeAcceptsRehearsalCircuit(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + definition := CeremonyDefinition{ + Schema: DefinitionSchema, + Mode: ModeRehearsal, + Circuit: circuit.Binding, + } + // The definition is otherwise empty, so validation fails on later fields. + // What matters is that it does not fail on the circuit identity. + err = definition.validate(false) + if err != nil && strings.Contains(err.Error(), "key_version") { + t.Fatalf("rehearsal mode rejected the rehearsal circuit: %v", err) + } +} + +// TestK21GateIgnoresRehearsalCircuit is the check that keeps a fast rehearsal +// from ever satisfying a production gate. K21RehearsalEvidence must continue to +// demand the production circuit at domain 2^21 regardless of what the registry +// now knows about. +func TestK21GateIgnoresRehearsalCircuit(t *testing.T) { + circuit, err := CompileForKeyVersion(KeyVersionRehearsal) + if err != nil { + t.Fatal(err) + } + evidence := K21RehearsalEvidence{ + KeyVersion: circuit.Binding.KeyVersion, + CircuitID: circuit.Binding.CircuitID, + Curve: circuit.Binding.Curve, + Backend: circuit.Binding.Backend, + Constraints: circuit.Binding.Constraints, + DomainSize: circuit.Binding.DomainSize, + } + if err := evidence.Validate(); err == nil { + t.Fatal("the K21 rehearsal gate accepted evidence from the tiny rehearsal circuit") + } +} diff --git a/internal/mpcrehearsal/config.go b/internal/mpcrehearsal/config.go new file mode 100644 index 0000000..dfe8754 --- /dev/null +++ b/internal/mpcrehearsal/config.go @@ -0,0 +1,243 @@ +// Package mpcrehearsal creates fresh same-host identities and exact canonical +// inputs for a local MPC ceremony rehearsal. It is deliberately not a +// production enrollment tool: production identities must be generated and +// governed independently by their owners. +package mpcrehearsal + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "proof-tool/internal/mpcceremony" +) + +const ( + minRehearsalParticipants = 3 + maxRehearsalParticipants = 20 + minRehearsalBeaconLead = 60 +) + +type generatedIdentity struct { + identity mpcceremony.Identity + privateKey ed25519.PrivateKey +} + +func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (err error) { + if participantCount < minRehearsalParticipants || + participantCount > maxRehearsalParticipants { + return fmt.Errorf( + "participants must be between %d and %d", + minRehearsalParticipants, + maxRehearsalParticipants, + ) + } + if beaconWitnessLead < minRehearsalBeaconLead { + return fmt.Errorf( + "beacon witness lead must be at least %d seconds", + minRehearsalBeaconLead, + ) + } + if err := os.Mkdir(outDir, 0o700); err != nil { + return fmt.Errorf("create fresh rehearsal config root: %w", err) + } + removeRoot := true + defer func() { + if err != nil && removeRoot { + _ = os.RemoveAll(outDir) + } + }() + keyDir := filepath.Join(outDir, "keys") + configDir := filepath.Join(outDir, "config") + for _, path := range []string{keyDir, configDir} { + if err := os.Mkdir(path, 0o700); err != nil { + return err + } + } + + newIdentity := func(id, displayName string) (generatedIdentity, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return generatedIdentity{}, err + } + identity, err := mpcceremony.NewIdentity( + id, + displayName, + id+"-key", + publicKey, + ) + if err != nil { + return generatedIdentity{}, err + } + return generatedIdentity{identity: identity, privateKey: privateKey}, nil + } + + coordinator, err := newIdentity("coordinator", "Local Rehearsal Coordinator") + if err != nil { + return err + } + releaseSigner, err := newIdentity("release-signer", "Local Rehearsal Release Signer") + if err != nil { + return err + } + auditor1, err := newIdentity("auditor-01", "Local Rehearsal Auditor 01") + if err != nil { + return err + } + auditor2, err := newIdentity("auditor-02", "Local Rehearsal Auditor 02") + if err != nil { + return err + } + witness1, err := newIdentity("witness-01", "Local Rehearsal Public Witness 01") + if err != nil { + return err + } + witness2, err := newIdentity("witness-02", "Local Rehearsal Public Witness 02") + if err != nil { + return err + } + mirror1, err := newIdentity("mirror-01", "Local Rehearsal Mirror Operator 01") + if err != nil { + return err + } + mirror2, err := newIdentity("mirror-02", "Local Rehearsal Mirror Operator 02") + if err != nil { + return err + } + generated := []generatedIdentity{ + coordinator, + releaseSigner, + auditor1, + auditor2, + witness1, + witness2, + mirror1, + mirror2, + } + participants := make([]mpcceremony.Participant, 0, participantCount) + participantIDs := make([]string, 0, participantCount) + for index := 1; index <= participantCount; index++ { + id := fmt.Sprintf("participant-%02d", index) + participant, err := newIdentity(id, "Local Rehearsal "+id) + if err != nil { + return err + } + generated = append(generated, participant) + participants = append(participants, mpcceremony.Participant{Identity: participant.identity}) + participantIDs = append(participantIDs, id) + } + + for _, item := range generated { + seedPath := filepath.Join(keyDir, item.identity.ID+".ed25519.private.hex") + if err := writeNoReplace( + seedPath, + []byte(hex.EncodeToString(item.privateKey.Seed())+"\n"), + 0o600, + ); err != nil { + return err + } + publicPath := filepath.Join(keyDir, item.identity.ID+".ed25519.public.hex") + if err := writeNoReplace( + publicPath, + []byte(item.identity.Ed25519PublicKeyHex+"\n"), + 0o600, + ); err != nil { + return err + } + } + + enrollment := mpcceremony.InitParticipants{ + Coordinator: coordinator.identity, + ReleaseSigner: releaseSigner.identity, + Auditors: []mpcceremony.Identity{auditor1.identity, auditor2.identity}, + Roster: participants, + } + policy := mpcceremony.InitPolicy{ + Phase1Policy: mpcceremony.PhasePolicy{ + Participants: participantIDs, + Minimum: uint8(participantCount), + }, + Phase2Policy: mpcceremony.PhasePolicy{ + Participants: append([]string(nil), participantIDs...), + Minimum: uint8(participantCount), + }, + BeaconPolicy: mpcceremony.BeaconPolicy{ + Provider: mpcceremony.BeaconProviderDrand, + Network: mpcceremony.BeaconNetworkQuicknet, + ChainHashHex: mpcceremony.BeaconQuicknetChainHash, + PublicKeyHex: mpcceremony.BeaconQuicknetPublicKey, + Scheme: mpcceremony.BeaconQuicknetScheme, + GenesisTimeUnix: mpcceremony.BeaconQuicknetGenesis, + PeriodSeconds: mpcceremony.BeaconQuicknetPeriod, + Extraction: mpcceremony.BeaconExtractionV1, + MinimumChallengeBytes: 32, + MinimumWitnessLeadSeconds: beaconWitnessLead, + FutureRoundRequired: true, + }, + } + environment := mpcceremony.ContributionEnvironment{ + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + EntropySource: "operating-system-csprng", + SwapDisabled: true, + CrashDumpsDisabled: true, + TelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralDestructionRequired: true, + } + for name, value := range map[string]any{ + "participants.json": enrollment, + "policy.json": policy, + "environment.json": environment, + } { + data, err := mpcceremony.MarshalCanonical(value) + if err != nil { + return err + } + if err := writeNoReplace(filepath.Join(configDir, name), data, 0o600); err != nil { + return err + } + } + if err := writeNoReplace( + filepath.Join(outDir, "participant-count.txt"), + []byte(fmt.Sprintf("%d\n", participantCount)), + 0o600, + ); err != nil { + return err + } + removeRoot = false + return nil +} + +func writeNoReplace(path string, data []byte, mode os.FileMode) error { + if len(data) == 0 { + return errors.New("refusing to write empty rehearsal config") + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + remove := true + defer func() { + _ = file.Close() + if remove { + _ = os.Remove(path) + } + }() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + remove = false + return nil +} diff --git a/scripts/mpc-rehearsal-config/main.go b/scripts/mpc-rehearsal-config/main.go index fda685d..f4651d8 100644 --- a/scripts/mpc-rehearsal-config/main.go +++ b/scripts/mpc-rehearsal-config/main.go @@ -5,30 +5,13 @@ package main import ( - "crypto/ed25519" - "crypto/rand" - "encoding/hex" - "errors" "flag" "fmt" "os" - "path/filepath" - "runtime" - "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" ) -const ( - minRehearsalParticipants = 3 - maxRehearsalParticipants = 20 - minRehearsalBeaconLead = 60 -) - -type generatedIdentity struct { - identity mpcceremony.Identity - privateKey ed25519.PrivateKey -} - func main() { outDir := flag.String("out-dir", "", "fresh output directory") participantCount := flag.Int("participants", 3, "number of rehearsal participants (3-20)") @@ -53,216 +36,6 @@ func main() { fmt.Printf("OK: generated rehearsal-only identities and canonical config in %s\n", *outDir) } -func generate(outDir string, participantCount int, beaconWitnessLead uint32) (err error) { - if participantCount < minRehearsalParticipants || - participantCount > maxRehearsalParticipants { - return fmt.Errorf( - "participants must be between %d and %d", - minRehearsalParticipants, - maxRehearsalParticipants, - ) - } - if beaconWitnessLead < minRehearsalBeaconLead { - return fmt.Errorf( - "beacon witness lead must be at least %d seconds", - minRehearsalBeaconLead, - ) - } - if err := os.Mkdir(outDir, 0o700); err != nil { - return fmt.Errorf("create fresh rehearsal config root: %w", err) - } - removeRoot := true - defer func() { - if err != nil && removeRoot { - _ = os.RemoveAll(outDir) - } - }() - keyDir := filepath.Join(outDir, "keys") - configDir := filepath.Join(outDir, "config") - for _, path := range []string{keyDir, configDir} { - if err := os.Mkdir(path, 0o700); err != nil { - return err - } - } - - newIdentity := func(id, displayName string) (generatedIdentity, error) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return generatedIdentity{}, err - } - identity, err := mpcceremony.NewIdentity( - id, - displayName, - id+"-key", - publicKey, - ) - if err != nil { - return generatedIdentity{}, err - } - return generatedIdentity{identity: identity, privateKey: privateKey}, nil - } - - coordinator, err := newIdentity("coordinator", "Local Rehearsal Coordinator") - if err != nil { - return err - } - releaseSigner, err := newIdentity("release-signer", "Local Rehearsal Release Signer") - if err != nil { - return err - } - auditor1, err := newIdentity("auditor-01", "Local Rehearsal Auditor 01") - if err != nil { - return err - } - auditor2, err := newIdentity("auditor-02", "Local Rehearsal Auditor 02") - if err != nil { - return err - } - witness1, err := newIdentity("witness-01", "Local Rehearsal Public Witness 01") - if err != nil { - return err - } - witness2, err := newIdentity("witness-02", "Local Rehearsal Public Witness 02") - if err != nil { - return err - } - mirror1, err := newIdentity("mirror-01", "Local Rehearsal Mirror Operator 01") - if err != nil { - return err - } - mirror2, err := newIdentity("mirror-02", "Local Rehearsal Mirror Operator 02") - if err != nil { - return err - } - generated := []generatedIdentity{ - coordinator, - releaseSigner, - auditor1, - auditor2, - witness1, - witness2, - mirror1, - mirror2, - } - participants := make([]mpcceremony.Participant, 0, participantCount) - participantIDs := make([]string, 0, participantCount) - for index := 1; index <= participantCount; index++ { - id := fmt.Sprintf("participant-%02d", index) - participant, err := newIdentity(id, "Local Rehearsal "+id) - if err != nil { - return err - } - generated = append(generated, participant) - participants = append(participants, mpcceremony.Participant{Identity: participant.identity}) - participantIDs = append(participantIDs, id) - } - - for _, item := range generated { - seedPath := filepath.Join(keyDir, item.identity.ID+".ed25519.private.hex") - if err := writeNoReplace( - seedPath, - []byte(hex.EncodeToString(item.privateKey.Seed())+"\n"), - 0o600, - ); err != nil { - return err - } - publicPath := filepath.Join(keyDir, item.identity.ID+".ed25519.public.hex") - if err := writeNoReplace( - publicPath, - []byte(item.identity.Ed25519PublicKeyHex+"\n"), - 0o600, - ); err != nil { - return err - } - } - - enrollment := mpcceremony.InitParticipants{ - Coordinator: coordinator.identity, - ReleaseSigner: releaseSigner.identity, - Auditors: []mpcceremony.Identity{auditor1.identity, auditor2.identity}, - Roster: participants, - } - policy := mpcceremony.InitPolicy{ - Phase1Policy: mpcceremony.PhasePolicy{ - Participants: participantIDs, - Minimum: uint8(participantCount), - }, - Phase2Policy: mpcceremony.PhasePolicy{ - Participants: append([]string(nil), participantIDs...), - Minimum: uint8(participantCount), - }, - BeaconPolicy: mpcceremony.BeaconPolicy{ - Provider: mpcceremony.BeaconProviderDrand, - Network: mpcceremony.BeaconNetworkQuicknet, - ChainHashHex: mpcceremony.BeaconQuicknetChainHash, - PublicKeyHex: mpcceremony.BeaconQuicknetPublicKey, - Scheme: mpcceremony.BeaconQuicknetScheme, - GenesisTimeUnix: mpcceremony.BeaconQuicknetGenesis, - PeriodSeconds: mpcceremony.BeaconQuicknetPeriod, - Extraction: mpcceremony.BeaconExtractionV1, - MinimumChallengeBytes: 32, - MinimumWitnessLeadSeconds: beaconWitnessLead, - FutureRoundRequired: true, - }, - } - environment := mpcceremony.ContributionEnvironment{ - OS: runtime.GOOS, - Architecture: runtime.GOARCH, - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, - } - for name, value := range map[string]any{ - "participants.json": enrollment, - "policy.json": policy, - "environment.json": environment, - } { - data, err := mpcceremony.MarshalCanonical(value) - if err != nil { - return err - } - if err := writeNoReplace(filepath.Join(configDir, name), data, 0o600); err != nil { - return err - } - } - if err := writeNoReplace( - filepath.Join(outDir, "participant-count.txt"), - []byte(fmt.Sprintf("%d\n", participantCount)), - 0o600, - ); err != nil { - return err - } - removeRoot = false - return nil -} - -func writeNoReplace(path string, data []byte, mode os.FileMode) error { - if len(data) == 0 { - return errors.New("refusing to write empty rehearsal config") - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - remove := true - defer func() { - _ = file.Close() - if remove { - _ = os.Remove(path) - } - }() - if _, err := file.Write(data); err != nil { - return err - } - if err := file.Sync(); err != nil { - return err - } - if err := file.Close(); err != nil { - return err - } - remove = false - return nil +func generate(outDir string, participantCount int, beaconWitnessLead uint32) error { + return mpcrehearsal.Generate(outDir, participantCount, beaconWitnessLead) } From 93d20cd6abe69ebe9b1582b0d41614a93b673cf3 Mon Sep 17 00:00:00 2001 From: Jason Park <94618524+mellowcroc@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:23:12 +0900 Subject: [PATCH 2/2] docs(mpc-ceremony): finalize help reflects circuit-from-definition (#9) finalize prepare's help said it 'compiles this repository's destination-v2 R1CS', but executeFinalize/executeAudit resolve the circuit from the signed ceremony definition via compileCircuitForCeremony -> CompileForKeyVersion. A rehearsal-tiny-v1 ceremony is therefore finalized/audited against the rehearsal circuit, not destination-v2. The stale wording implies the tiny rehearsal cannot be finalized, which is incorrect. --- cmd/mpc-ceremony/usage.go | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 8684621..7af6d66 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -326,9 +326,10 @@ Records the distinct Phase 2 post-closure beacon evidence used by finalize. --out-dir FRESH_DIR ` + replayFlagsHelp + ` -Independently compiles this repository's destination-v2 R1CS, replays both -phases, and publishes a coordinator-signed preliminary native PK/VK tree. It -is not a candidate and cannot be audited or released. +Independently compiles the circuit named by the signed ceremony definition +(ownership-destination-v2 in production, rehearsal-tiny-v1 in a rehearsal), +replays both phases, and publishes a coordinator-signed preliminary native +PK/VK tree. It is not a candidate and cannot be audited or released. `, "finalize complete": `Usage: mpc-ceremony finalize complete --ceremony FILE --ceremony-signature FILE \