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/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 194ea5e..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"` @@ -137,6 +157,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/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/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/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 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") + } +}