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
112 changes: 112 additions & 0 deletions .github/workflows/ceremony-kit-compatibility.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
name: Ceremony kit binary compatibility

on:
workflow_dispatch:
inputs:
relay_repository:
description: Approved Relay OWNER/REPOSITORY
required: true
default: zksecurity/relay
type: string
relay_tag:
description: Approved Relay release tag
required: true
type: string
relay_sha256:
description: Approved Relay binary SHA-256
required: true
type: string
mpc_repository:
description: Approved proof-tool OWNER/REPOSITORY
required: true
default: Emurgo/proof-tool
type: string
mpc_tag:
description: Approved mpc-ceremony release tag
required: true
type: string
mpc_sha256:
description: Approved mpc-ceremony binary SHA-256
required: true
type: string

permissions:
contents: read

concurrency:
group: ceremony-kit-compatibility-${{ github.run_id }}
cancel-in-progress: false

jobs:
verify-released-binaries:
name: Verify exact released binary pair
runs-on: ubuntu-latest
timeout-minutes: 10
env:
RELAY_REPOSITORY: ${{ inputs.relay_repository }}
RELAY_TAG: ${{ inputs.relay_tag }}
RELAY_SHA256: ${{ inputs.relay_sha256 }}
MPC_REPOSITORY: ${{ inputs.mpc_repository }}
MPC_TAG: ${{ inputs.mpc_tag }}
MPC_SHA256: ${{ inputs.mpc_sha256 }}
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false

- name: Validate authenticated release inputs
shell: bash
run: |
set -euo pipefail
repository_pattern='^[A-Za-z0-9][A-Za-z0-9_.-]*/[A-Za-z0-9][A-Za-z0-9_.-]*$'
tag_pattern='^[A-Za-z0-9][A-Za-z0-9._+-]{0,127}$'
[[ "$RELAY_REPOSITORY" =~ $repository_pattern ]]
[[ "$MPC_REPOSITORY" =~ $repository_pattern ]]
[[ "$RELAY_TAG" =~ $tag_pattern ]]
[[ "$MPC_TAG" =~ $tag_pattern ]]
[[ "$RELAY_SHA256" =~ ^[0-9a-f]{64}$ ]]
[[ "$MPC_SHA256" =~ ^[0-9a-f]{64}$ ]]

- name: Download and authenticate released binaries
shell: bash
run: |
set -euo pipefail
mkdir "$RUNNER_TEMP/ceremony-kit-inputs"
curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \
"https://github.com/$RELAY_REPOSITORY/releases/download/$RELAY_TAG/relay" \
--output "$RUNNER_TEMP/ceremony-kit-inputs/relay"
curl --proto '=https' --tlsv1.2 --fail --location --silent --show-error \
"https://github.com/$MPC_REPOSITORY/releases/download/$MPC_TAG/mpc-ceremony" \
--output "$RUNNER_TEMP/ceremony-kit-inputs/mpc-ceremony"
printf '%s %s\n' "$RELAY_SHA256" "$RUNNER_TEMP/ceremony-kit-inputs/relay" | \
sha256sum --check --strict
printf '%s %s\n' "$MPC_SHA256" "$RUNNER_TEMP/ceremony-kit-inputs/mpc-ceremony" | \
sha256sum --check --strict
chmod 0755 \
"$RUNNER_TEMP/ceremony-kit-inputs/relay" \
"$RUNNER_TEMP/ceremony-kit-inputs/mpc-ceremony"

- name: Exercise the released CLI boundary
shell: bash
run: |
set -euo pipefail
mkdir "$RUNNER_TEMP/ceremony-kit-evidence"
scripts/verify-ceremony-kit-compatibility.sh \
--relay-binary "$RUNNER_TEMP/ceremony-kit-inputs/relay" \
--relay-sha256 "$RELAY_SHA256" \
--mpc-binary "$RUNNER_TEMP/ceremony-kit-inputs/mpc-ceremony" \
--mpc-sha256 "$MPC_SHA256" \
--evidence-out "$RUNNER_TEMP/ceremony-kit-evidence/compatibility.json"
evidence_sha256=$(sha256sum \
"$RUNNER_TEMP/ceremony-kit-evidence/compatibility.json")
{
printf '## Ceremony-kit compatibility passed\n\n'
printf -- '- Relay: `%s@%s` (`%s`)\n' \
"$RELAY_REPOSITORY" "$RELAY_TAG" "$RELAY_SHA256"
printf -- '- mpc-ceremony: `%s@%s` (`%s`)\n' \
"$MPC_REPOSITORY" "$MPC_TAG" "$MPC_SHA256"
printf -- '- Evidence SHA-256: `%s`\n\n' "${evidence_sha256%% *}"
printf '```json\n'
cat "$RUNNER_TEMP/ceremony-kit-evidence/compatibility.json"
printf '```\n'
} >>"$GITHUB_STEP_SUMMARY"
19 changes: 16 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,7 @@ jobs:
scripts/build-relay-release.sh \
scripts/build-ceremony-kit.sh \
scripts/setup-ceremony-kit.sh \
scripts/verify-ceremony-kit-compatibility.sh \
scripts/storage-setup/*.sh \
scripts/verify-relay-release.sh \
scripts/verify-relay-reproducible.sh \
Expand Down Expand Up @@ -270,7 +271,18 @@ jobs:
mkdir \
"$RUNNER_TEMP/ceremony-kit-parent" \
"$RUNNER_TEMP/ceremony-kit-install" \
"$RUNNER_TEMP/ceremony-kit-operator"
"$RUNNER_TEMP/ceremony-kit-operator" \
"$RUNNER_TEMP/incompatible-evidence"
if scripts/verify-ceremony-kit-compatibility.sh \
--relay-binary "$RELEASE_DIR/relay" \
--relay-sha256 "$RELAY_SHA256" \
--mpc-binary "$RELEASE_DIR/relay" \
--mpc-sha256 "$RELAY_SHA256" \
--evidence-out "$RUNNER_TEMP/incompatible-evidence/compatibility.json" \
>/dev/null 2>&1; then
echo "Relay was accepted as an mpc-ceremony binary" >&2
exit 1
fi
if scripts/build-ceremony-kit.sh \
--mode production \
--relay-release-dir "$RELEASE_DIR" \
Expand All @@ -291,10 +303,10 @@ jobs:
--relay-repository zksecurity/relay \
--relay-tag ci-rehearsal \
--relay-sha256 "$RELAY_SHA256" \
--mpc-binary "$RELEASE_DIR/relay" \
--mpc-binary scripts/testdata/mpc-ceremony-compatibility-stub.sh \
--mpc-repository zksecurity/proof-tool \
--mpc-tag ci-rehearsal \
--mpc-sha256 "$RELAY_SHA256" \
--mpc-sha256 "$(sha256sum scripts/testdata/mpc-ceremony-compatibility-stub.sh | cut -d ' ' -f 1)" \
--include-rehearsal \
--out-dir "$RUNNER_TEMP/ceremony-kit-parent/ceremony-kit"
KIT_SOURCE_DATE_EPOCH=$(<"$RELEASE_DIR/source-date-epoch.txt")
Expand All @@ -306,6 +318,7 @@ jobs:
tar -tzf "$CEREMONY_KIT_ARCHIVE" >"$RUNNER_TEMP/ceremony-kit-contents.txt"
grep -Fx 'ceremony-kit/' "$RUNNER_TEMP/ceremony-kit-contents.txt"
grep -Fx 'ceremony-kit/setup' "$RUNNER_TEMP/ceremony-kit-contents.txt"
grep -Fx 'ceremony-kit/compatibility.json' "$RUNNER_TEMP/ceremony-kit-contents.txt"
! grep -Fx './setup' "$RUNNER_TEMP/ceremony-kit-contents.txt"
mkdir "$RUNNER_TEMP/ceremony-kit-extracted"
tar --no-same-owner -xzf "$CEREMONY_KIT_ARCHIVE" \
Expand Down
121 changes: 121 additions & 0 deletions cmd/relay/compatibility.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,121 @@
package main

import (
"errors"
"flag"
"fmt"
"os"
"path/filepath"
"time"

"github.com/zksecurity/relay/internal/transcript"
)

// runVerifyCeremonyPair is intentionally hidden under advanced. Release-kit
// assembly uses it to exercise the exact Relay and mpc-ceremony binaries
// against proof-tool's same-host tiny rehearsal. It never accesses storage and
// refuses non-rehearsal definitions.
func runVerifyCeremonyPair(args []string) error {
set := flag.NewFlagSet("advanced verify-ceremony-pair", flag.ContinueOnError)
var home, ceremonyBinary string
set.StringVar(&home, "home", "", "fresh rehearsal root created by mpc-ceremony rehearsal init")
set.StringVar(&ceremonyBinary, "ceremony-binary", "", "exact mpc-ceremony release binary")
if err := set.Parse(args); err != nil {
return err
}
if home == "" || ceremonyBinary == "" {
return errors.New("--home and --ceremony-binary are required")
}
if !filepath.IsAbs(home) || filepath.Clean(home) != home {
return errors.New("--home must be an absolute clean path")
}
if info, err := os.Lstat(ceremonyBinary); err != nil || !info.Mode().IsRegular() || info.Mode()&0o111 == 0 {
return errors.New("--ceremony-binary must be an executable non-symlink regular file")
}

root := filepath.Join(home, "public")
o := roleOpts{
root: root,
definition: filepath.Join(root, "ceremony.json"),
definitionSig: filepath.Join(root, "ceremony.sig"),
coordinatorKey: filepath.Join(root, "coordinator-public-key.hex"),
ceremonyBinary: ceremonyBinary,
phase: "phase1",
role: "participant-01",
signingKey: filepath.Join(home, "keys", "participant-01.ed25519.private.hex"),
envPath: filepath.Join(home, "config", "environment.json"),
outDir: filepath.Join(home, "compatibility", "phase1-participant-01"),
}
inspector := transcript.Inspector{
Executable: ceremonyBinary, CeremonyPath: o.definition,
CeremonySignaturePath: o.definitionSig, CoordinatorPublicKeyPath: o.coordinatorKey,
TranscriptRoot: root,
}
definition, err := inspector.Definition()
if err != nil {
return fmt.Errorf("inspect rehearsal definition: %w", err)
}
if definition.Mode != "rehearsal" {
return fmt.Errorf("compatibility exercise requires rehearsal mode, found %q", definition.Mode)
}
participant, err := inspector.Participant(o.signingKey)
if err != nil {
return fmt.Errorf("inspect rehearsal participant: %w", err)
}
if participant.ParticipantID != o.role {
return fmt.Errorf("rehearsal key belongs to %s, want %s", participant.ParticipantID, o.role)
}

chainPath := filepath.Join(root, "phase1", "chain-0000.json")
chainSignaturePath := filepath.Join(root, "phase1", "chain-0000.sig")
chain, err := inspector.Chain(chainPath, chainSignaturePath)
if err != nil {
return fmt.Errorf("inspect initial rehearsal chain: %w", err)
}
if chain.Phase != o.phase || chain.AcceptedCount() != 0 {
return fmt.Errorf("initial rehearsal chain is %s with %d accepted contributions", chain.Phase, chain.AcceptedCount())
}
nextID, nextIndex, err := definition.NextContributor(o.phase, chain.AcceptedCount())
if err != nil {
return err
}
if nextID != o.role || nextIndex != 1 {
return fmt.Errorf("initial rehearsal turn is %s at index %d, want %s at index 1", nextID, nextIndex, o.role)
}
if err := os.Mkdir(filepath.Dir(o.outDir), 0o700); err != nil {
return fmt.Errorf("create compatibility output parent: %w", err)
}
pos := position{
definition: definition, chain: chain, chainPath: chainPath,
nextID: nextID, nextIndex: nextIndex,
}
contributedAt := time.Now().UTC().Truncate(time.Second)
if err := runNextAt(o, pos, contributedAt); err != nil {
return fmt.Errorf("run tiny rehearsal contribution: %w", err)
}
if err := runErasureAt(o, contributedAt.Add(time.Second)); err != nil {
return fmt.Errorf("create tiny rehearsal erasure attestation: %w", err)
}

cmd := candidateVerificationCommand(
o, chainPath, chainSignaturePath, o.outDir,
filepath.Join(home, "keys", "coordinator.ed25519.private.hex"),
defaultAcceptanceTimestamp(contributedAt.Add(2*time.Second)),
)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := runWithProgress("verifying tiny rehearsal contribution", cmd.Run); err != nil {
return err
}
accepted, err := inspector.Chain(
filepath.Join(root, "phase1", "chain-0001.json"),
filepath.Join(root, "phase1", "chain-0001.sig"),
)
if err != nil {
return fmt.Errorf("inspect accepted rehearsal chain: %w", err)
}
if accepted.AcceptedCount() != 1 || accepted.Records[0].ParticipantID != o.role {
return fmt.Errorf("accepted rehearsal chain does not contain %s at index 1", o.role)
}
fmt.Println("verified exact-binary phase1 contribution and acceptance")
return nil
}
48 changes: 33 additions & 15 deletions cmd/relay/coordinator_workflow.go
Original file line number Diff line number Diff line change
Expand Up @@ -116,7 +116,8 @@ func runAcceptCandidate(args []string) error {
root: root, definition: config.CeremonyPath, definitionSig: config.CeremonySignature,
coordinatorKey: config.CoordinatorPublicKey, ceremonyBinary: config.CeremonyBinary,
phase: manifest.Phase, role: manifest.ParticipantID,
client: store.Client{Bucket: config.PublishedBucket, PublicBaseURL: config.PublishedBaseURL},
client: store.Client{Bucket: config.PublishedBucket, PublicBaseURL: config.PublishedBaseURL},
phase1Seal: phase1Seal, phase1SealSig: phase1SealSignature,
}
pos, err := resolvePosition(o)
if err != nil {
Expand Down Expand Up @@ -170,20 +171,10 @@ func runAcceptCandidate(args []string) error {
if acceptedAt == "" {
acceptedAt = defaultAcceptanceTimestamp(time.Now())
}
command := []string{manifest.Phase, "verify", "--ceremony", config.CeremonyPath,
"--ceremony-signature", config.CeremonySignature, "--coordinator-public-key-file", config.CoordinatorPublicKey,
"--transcript-dir", root, "--chain", pos.chainPath, "--chain-signature", pos.chain.ChainSignaturePath,
"--candidate-dir", candidateDir, "--coordinator-signing-key", coordinatorSigningKey, "--accepted-at", acceptedAt}
if manifest.Phase == "phase2" {
if phase1Seal == "" {
phase1Seal = filepath.Join(root, "phase1", "sealed", "seal.json")
}
if phase1SealSignature == "" {
phase1SealSignature = filepath.Join(root, "phase1", "sealed", "seal.sig")
}
command = append(command, "--phase1-seal", phase1Seal, "--phase1-seal-signature", phase1SealSignature)
}
cmd := exec.Command(config.CeremonyBinary, command...)
cmd := candidateVerificationCommand(
o, pos.chainPath, pos.chain.ChainSignaturePath, candidateDir,
coordinatorSigningKey, acceptedAt,
)
cmd.Stdout, cmd.Stderr = os.Stdout, os.Stderr
if err := runWithProgress("verifying contribution candidate", cmd.Run); err != nil {
return err
Expand Down Expand Up @@ -211,6 +202,33 @@ func defaultAcceptanceTimestamp(now time.Time) string {
return now.UTC().Format(time.RFC3339Nano)
}

// candidateVerificationCommand is shared by the coordinator acceptance path
// and the source-free release-pair gate. Keeping the argv construction here
// prevents the release gate from testing a second approximation of Relay's
// mpc-ceremony interface.
func candidateVerificationCommand(
o roleOpts,
chainPath, chainSignaturePath, candidateDir, coordinatorSigningKey, acceptedAt string,
) *exec.Cmd {
command := []string{o.phase, "verify", "--ceremony", o.definition,
"--ceremony-signature", o.definitionSig, "--coordinator-public-key-file", o.coordinatorKey,
"--transcript-dir", o.root, "--chain", chainPath, "--chain-signature", chainSignaturePath,
"--candidate-dir", candidateDir, "--coordinator-signing-key", coordinatorSigningKey,
"--accepted-at", acceptedAt}
if o.phase == "phase2" {
phase1Seal := o.phase1Seal
phase1SealSignature := o.phase1SealSig
if phase1Seal == "" {
phase1Seal = filepath.Join(o.root, "phase1", "sealed", "seal.json")
}
if phase1SealSignature == "" {
phase1SealSignature = filepath.Join(o.root, "phase1", "sealed", "seal.sig")
}
command = append(command, "--phase1-seal", phase1Seal, "--phase1-seal-signature", phase1SealSignature)
}
return exec.Command(o.ceremonyExecutable(), command...)
}

func runEvidenceInbox(args []string) error {
set := flag.NewFlagSet("coordinator evidence", flag.ContinueOnError)
var storagePath, role string
Expand Down
31 changes: 31 additions & 0 deletions cmd/relay/coordinator_workflow_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package main

import (
"reflect"
"testing"
"time"
)
Expand All @@ -21,3 +22,33 @@ func TestDefaultAcceptanceTimestampPreservesSubsecondOrdering(t *testing.T) {
t.Fatalf("accepted_at = %q, want %q", encoded, acceptedAt.Format(time.RFC3339Nano))
}
}

func TestCandidateVerificationCommandUsesOperationalCLI(t *testing.T) {
o := roleOpts{
root: "/ceremony", definition: "/ceremony/ceremony.json",
definitionSig: "/ceremony/ceremony.sig", coordinatorKey: "/trust/coordinator.hex",
ceremonyBinary: "/trusted/mpc-ceremony", phase: "phase2",
phase1Seal: "/sealed/phase1.json", phase1SealSig: "/sealed/phase1.sig",
}
command := candidateVerificationCommand(
o, "/ceremony/phase2/chain-0000.json", "/ceremony/phase2/chain-0000.sig",
"/candidate", "/keys/coordinator.private.hex", "2026-08-20T12:00:02Z",
)
want := []string{
"/trusted/mpc-ceremony", "phase2", "verify",
"--ceremony", "/ceremony/ceremony.json",
"--ceremony-signature", "/ceremony/ceremony.sig",
"--coordinator-public-key-file", "/trust/coordinator.hex",
"--transcript-dir", "/ceremony",
"--chain", "/ceremony/phase2/chain-0000.json",
"--chain-signature", "/ceremony/phase2/chain-0000.sig",
"--candidate-dir", "/candidate",
"--coordinator-signing-key", "/keys/coordinator.private.hex",
"--accepted-at", "2026-08-20T12:00:02Z",
"--phase1-seal", "/sealed/phase1.json",
"--phase1-seal-signature", "/sealed/phase1.sig",
}
if !reflect.DeepEqual(command.Args, want) {
t.Fatalf("candidate verification argv = %#v, want %#v", command.Args, want)
}
}
Loading