Skip to content
Closed
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
22 changes: 18 additions & 4 deletions cmd/mpc-ceremony/executor.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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
}
Expand Down Expand Up @@ -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)
}
20 changes: 17 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 @@ -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")
Expand All @@ -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)
Expand Down
73 changes: 73 additions & 0 deletions internal/circuit/rehearsal/circuit.go
Original file line number Diff line number Diff line change
@@ -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}
}
4 changes: 3 additions & 1 deletion internal/mpcceremony/chain_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
38 changes: 36 additions & 2 deletions internal/mpcceremony/definition.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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"`
Expand Down Expand Up @@ -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")
}
Expand Down
5 changes: 3 additions & 2 deletions internal/mpcceremony/inspection_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Expand Down
27 changes: 22 additions & 5 deletions internal/mpcceremony/model.go
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -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)
Expand Down
4 changes: 2 additions & 2 deletions internal/mpcceremony/operational_bundle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
13 changes: 10 additions & 3 deletions internal/mpcceremony/operational_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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
Loading