From dedc8d9e800b3a77b025c3fa38a659c32c6988c0 Mon Sep 17 00:00:00 2001 From: Jason Park Date: Sat, 15 Aug 2026 14:00:23 +0000 Subject: [PATCH 1/2] Add a tiny rehearsal circuit so the ceremony is testable The destination-v2 circuit has 1,789,750 constraints and forces a 2^21 domain, which makes every ceremony operation expensive: a single phase-1 contribution takes 56 minutes and moves 576 MiB, and a full lifecycle is a multi-day exercise. Testing the orchestration at that size is impractical, so in practice it was not tested at all. Register a second circuit that proves x^3 = pub at a small domain, so the same lifecycle runs in minutes. It carries exactly one Groth16 commitment, matching destination-v2, because finalization exports a Cardano verifying key whose BSB22 encoding assumes a single commitment; without it the rehearsal could not reach the finalize stage at all. Production must never see this circuit, and three independent things keep it out. CeremonyDefinition.validate rejects any key version other than destination-v2 when mode is production, and does so before any environment-dependent check. The CLI refuses --key-version rehearsal-tiny-v1 unless --mode rehearsal. The exact-k21-rehearsal gate in the production decision still demands domain 2^21, which a small run cannot satisfy. CircuitBinding.Validate now checks key version and circuit id as a pair rather than each against a single constant. Checking them independently would let a definition name one circuit's version with another's id and pass both checks while describing nothing that exists. The four executor sites that previously hardcoded CompileDestinationV2 now compile the circuit the signed definition names, so an operator cannot select a circuit the ceremony was not created with. --- cmd/mpc-ceremony/executor.go | 22 ++- cmd/mpc-ceremony/parse.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 ++++++++++++++++++ 7 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 internal/circuit/rehearsal/circuit.go create mode 100644 internal/mpcceremony/rehearsal_circuit_test.go diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index c6bb654..ab57631 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -120,7 +120,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 +454,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 +503,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 +548,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 +898,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/parse.go b/cmd/mpc-ceremony/parse.go index e28d512..0aca70c 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 } @@ -605,7 +611,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 +624,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/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") + } +} From a65fff00f788428135752e517ea3aa5bd0f28fbe Mon Sep 17 00:00:00 2001 From: Jason Park Date: Sat, 15 Aug 2026 14:34:54 +0000 Subject: [PATCH 2/2] TEST BRANCH: cut the production witness lead to 10 minutes DO NOT MERGE. Any transcript produced from this branch is a test artifact and must never be presented as a ceremony. The released ProductionMinimumWitnessLeadSeconds is 24 hours. It is the window in which a public witness can observe a phase closure before the randomness that seals that phase exists, which is what stops a coordinator who already knows the beacon output from closing the phase around it. Two phase closes make it 48 hours of mandated waiting, and that is the point rather than an accident. That cost makes the production path effectively untestable: a run cannot finish in less than three days, so the finalize, audit, release, and decision stages have never been exercised. A rehearsal reaches none of them, because the production arm of CeremonyDefinition.validate is the only place several of those checks live. Reducing the constant to 10 minutes brings a full production run inside a single working session. The witness observation window introduced on the base branch is cut proportionally, from one hour to two minutes, so the reproduction still exercises the reserved-window check without restoring the multi-hour wait. Nothing else is relaxed: the clean-tree requirement, the pinned build profile, the destination-v2 circuit binding, and every other production gate remain exactly as released. --- internal/mpcceremony/chain_test.go | 4 +++- internal/mpcceremony/definition.go | 24 +++++++++++++++++-- internal/mpcceremony/inspection_test.go | 5 ++-- .../mpcceremony/operational_bundle_test.go | 4 ++-- internal/mpcceremony/operational_test.go | 13 +++++++--- 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/internal/mpcceremony/chain_test.go b/internal/mpcceremony/chain_test.go index 48e5f4e..3fd7a76 100644 --- a/internal/mpcceremony/chain_test.go +++ b/internal/mpcceremony/chain_test.go @@ -227,7 +227,9 @@ func TestValidateBeaconRecomputesAgainstBoundClose(t *testing.T) { t.Fatalf("production close reserving the witness window rejected: %v", err) } belowLead := exactLead - belowLead.ClosedAt = "2026-07-23T14:01:00.000000001Z" + // Derived from the round rather than written literally, so this stays one + // nanosecond inside the boundary whatever the signed minimum lead is. + belowLead.ClosedAt = roundTime.Add(-minimumLead + time.Nanosecond).Format(time.RFC3339Nano) belowLead, err = NewCloseRecord(belowLead) if err != nil { t.Fatal(err) diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index abad5d0..7b251dd 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -5,7 +5,23 @@ import ( "fmt" ) -const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 +// WARNING: THIS BRANCH IS NOT FIT FOR A REAL CEREMONY. +// +// The released value is 24 hours. It is the window in which a public witness +// can observe a phase closure before the randomness that seals that phase +// exists, which is what stops a coordinator who already knows the beacon +// output from closing the phase around it. Two phase closes make it 48 hours +// of mandated waiting, and that is the intended cost. +// +// It is reduced to 10 minutes here for one purpose: exercising the production +// code path end to end in hours instead of days, so the audit can reach the +// finalize, audit, release, and decision stages that no rehearsal run touches. +// +// Any transcript produced from this branch is a test artifact. It records this +// commit in its software binding, so a verifier who fetches the named revision +// finds this comment; do not merge this branch, and do not present its output +// as a ceremony. +const ProductionMinimumWitnessLeadSeconds uint32 = 10 * 60 // ProductionWitnessObservationWindowSeconds is the observation time a // production close must reserve for public witnesses on top of the signed @@ -20,7 +36,11 @@ const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 // signed closure. Reserving an explicit window at close keeps the witness // requirement satisfiable. Rehearsals are exempt: their leads are minutes and // their witness receipts are same-host fixtures. -const ProductionWitnessObservationWindowSeconds uint32 = 60 * 60 +// +// TEST BRANCH: cut from one hour to two minutes, proportionally with the +// 10-minute lead above, so the reproduction still exercises the reserved +// window without restoring the multi-hour wait. Same caveat: do not merge. +const ProductionWitnessObservationWindowSeconds uint32 = 2 * 60 type CeremonyDefinition struct { Schema string `json:"schema"` diff --git a/internal/mpcceremony/inspection_test.go b/internal/mpcceremony/inspection_test.go index 3dd1bef..00bcb19 100644 --- a/internal/mpcceremony/inspection_test.go +++ b/internal/mpcceremony/inspection_test.go @@ -135,7 +135,8 @@ func TestPreparePublicWitnessReceiptEnforcesRoleIdentityAndObservationTiming(t * t.Fatal(err) } location := "https://independent.example/phase1/closure.json" - observedAt := roundTime.Add(-24 * time.Hour).Format(time.RFC3339) + minimumLead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second + observedAt := roundTime.Add(-minimumLead).Format(time.RFC3339) receipt, canonical, err := PreparePublicWitnessReceipt( definition, closeRecord, @@ -176,7 +177,7 @@ func TestPreparePublicWitnessReceiptEnforcesRoleIdentityAndObservationTiming(t * {name: "before closure", observedAt: roundTime.Add(-26 * time.Hour)}, {name: "at beacon round", observedAt: roundTime}, {name: "after beacon round", observedAt: roundTime.Add(time.Second)}, - {name: "below minimum lead", observedAt: roundTime.Add(-24*time.Hour + time.Second)}, + {name: "below minimum lead", observedAt: roundTime.Add(-minimumLead + time.Second)}, } { t.Run(test.name, func(t *testing.T) { if _, _, err := PreparePublicWitnessReceipt( diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 8e95b89..7adf674 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -289,7 +289,7 @@ func TestVerifyOperationalEvidenceBundleEndToEndAndNegatives(t *testing.T) { t.Fatal(err) } roundTime, _ := QuicknetRoundTime(receipt.BeaconRound) - receipt.ObservedAt = roundTime.Add(-24*time.Hour + time.Second).Format(time.RFC3339) + receipt.ObservedAt = roundTime.Add(-productionWitnessLead + time.Second).Format(time.RFC3339) rewriteSignedPair(t, f.root, pair, receipt, f.witnessKeys[receipt.Witness.ID]) f.bundle.Phase1.PublicWitnessReceipts[0] = refreshPair(t, f.root, pair) resignBundle(t, &f) @@ -801,7 +801,7 @@ func buildOperationalPhaseFixture( witness.identity, closePair.Record.Name, taggedSHA256([]byte("https://public.example/"+string(phase)+"/"+witness.identity.ID)), - roundTime.Add(-24*time.Hour).Format(time.RFC3339), + roundTime.Add(-productionWitnessLead).Format(time.RFC3339), ) if err != nil { t.Fatal(err) diff --git a/internal/mpcceremony/operational_test.go b/internal/mpcceremony/operational_test.go index 95a48d9..73afe8e 100644 --- a/internal/mpcceremony/operational_test.go +++ b/internal/mpcceremony/operational_test.go @@ -184,13 +184,13 @@ func TestPublicWitnessLeadBoundaryAndActorIndependence(t *testing.T) { BeaconScheduledAt: roundTime.Format(time.RFC3339), PublicationLocationSHA: taggedSHA256([]byte("https://independent.example/phase1/closure.json")), Witness: witness, - ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339), + ObservedAt: roundTime.Add(-productionWitnessLead).Format(time.RFC3339), } if err := ValidatePublicWitnessReceipt(definition, close, closeBytes, receipt); err != nil { t.Fatalf("exact signed minimum lead rejected: %v", err) } tooLate := receipt - tooLate.ObservedAt = roundTime.Add(-24*time.Hour + time.Second).Format(time.RFC3339) + tooLate.ObservedAt = roundTime.Add(-productionWitnessLead + time.Second).Format(time.RFC3339) if err := ValidatePublicWitnessReceipt(definition, close, closeBytes, tooLate); err == nil { t.Fatal("below-minimum witness lead unexpectedly accepted") } @@ -224,7 +224,7 @@ func TestPublicWitnessQuorumRejectsDuplicateIdentityAndKey(t *testing.T) { BeaconScheduledAt: roundTime.Format(time.RFC3339), PublicationLocationSHA: taggedSHA256([]byte(id)), Witness: identity, - ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339), + ObservedAt: roundTime.Add(-productionWitnessLead).Format(time.RFC3339), } recordBytes, signatureBytes, err := SignRecord(record, identity.KeyID, privateKey) if err != nil { @@ -365,3 +365,10 @@ func operationalClose( } return record } + +// productionWitnessLead is the signed minimum witness lead as a duration. +// +// The boundary tests derive their timestamps from this rather than hardcoding +// 24 hours, so they keep testing the boundary rather than a fixed offset that +// happens to sit above it. +var productionWitnessLead = time.Duration(ProductionMinimumWitnessLeadSeconds) * time.Second