Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion .github/workflows/mpc-ceremony-release-validation.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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: |
Expand Down
6 changes: 6 additions & 0 deletions cmd/mpc-ceremony/cli_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
24 changes: 20 additions & 4 deletions cmd/mpc-ceremony/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
2 changes: 2 additions & 0 deletions cmd/mpc-ceremony/integration_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) {
topics := [][]string{
nil,
{"init"},
{"rehearsal"},
{"rehearsal", "init"},
{"phase1"},
{"phase1", "contribute"},
{"phase1", "attest-erasure"},
Expand Down
6 changes: 4 additions & 2 deletions cmd/mpc-ceremony/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand Down
56 changes: 53 additions & 3 deletions cmd/mpc-ceremony/parse.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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:])
Expand Down Expand Up @@ -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"}}
Expand Down Expand Up @@ -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")
Expand All @@ -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)
Expand Down
63 changes: 63 additions & 0 deletions cmd/mpc-ceremony/rehearsal.go
Original file line number Diff line number Diff line change
@@ -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
}
62 changes: 62 additions & 0 deletions cmd/mpc-ceremony/rehearsal_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
6 changes: 6 additions & 0 deletions cmd/mpc-ceremony/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -71,6 +72,11 @@ type InitOptions struct {
Mode string
}

type RehearsalInitOptions struct {
CreatedAt string
OutDir string
}

type ContributeOptions struct {
CeremonyPath string
CeremonySignaturePath string
Expand Down
Loading
Loading