diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6779f8c..c00e299 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -78,6 +78,12 @@ jobs: - name: Test run: go test -timeout 15m ./... + - name: Race-sensitive MPC parallel paths + run: | + go test -race -count=1 \ + github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup + go test -race -count=1 ./internal/mpcceremony + - name: WASM prover builds run: | GOOS=js GOARCH=wasm go build -o /dev/null ./cmd/wasm-prover diff --git a/.github/workflows/mpc-ceremony-release-validation.yml b/.github/workflows/mpc-ceremony-release-validation.yml new file mode 100644 index 0000000..b2d5e9c --- /dev/null +++ b/.github/workflows/mpc-ceremony-release-validation.yml @@ -0,0 +1,118 @@ +name: MPC ceremony release validation + +on: + push: + branches: [main] + pull_request: + workflow_dispatch: + +permissions: + contents: read + +concurrency: + group: mpc-ceremony-release-${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: ${{ github.ref != 'refs/heads/main' }} + +jobs: + rehearsal-reproducibility: + name: Reproducible unsigned rehearsal + runs-on: ubuntu-latest + timeout-minutes: 30 + steps: + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + fetch-depth: 0 + persist-credentials: false + + - uses: actions/setup-go@b7ad1dad31e06c5925ef5d2fc7ad053ef454303e # v7.0.0 + with: + go-version-file: go.mod + cache: false + + - name: Verify release inputs + shell: bash + run: | + test "$(go env GOVERSION)" = go1.26.6 + test "$(go env GOHOSTOS)" = linux + test "$(go env GOHOSTARCH)" = amd64 + test "$(sed -n 's/^module //p' go.mod)" = proof-tool + + - name: Bootstrap patched vendor tree + run: bash scripts/bootstrap-vendor.sh + + - name: Build two unsigned rehearsals + shell: bash + run: | + mkdir "$RUNNER_TEMP/mpc-rehearsal-a-parent" + mkdir "$RUNNER_TEMP/mpc-rehearsal-b-parent" + scripts/build-mpc-ceremony-release.sh \ + --mode rehearsal \ + --out-dir "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" + scripts/build-mpc-ceremony-release.sh \ + --mode rehearsal \ + --out-dir "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + + - name: Verify byte-for-byte reproducibility + shell: bash + run: | + scripts/verify-mpc-ceremony-reproducible.sh \ + --mode rehearsal \ + --expected-commit "$GITHUB_SHA" \ + --expected-tag none \ + --tag-signer-fingerprint none \ + --trusted-build-public-key-file none \ + "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" \ + "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + + - name: Exercise download-only tiny rehearsal initialization + shell: bash + run: | + set -euo pipefail + ceremony_binary="$RUNNER_TEMP/mpc-rehearsal-a-parent/release/mpc-ceremony" + rehearsal_root="$RUNNER_TEMP/downloadable-tiny-rehearsal" + "$ceremony_binary" rehearsal init \ + --created-at 2026-08-20T06:00:00Z \ + --out-dir "$rehearsal_root" + "$ceremony_binary" --format json inspect definition \ + --ceremony "$rehearsal_root/public/ceremony.json" \ + --ceremony-signature "$rehearsal_root/public/ceremony.sig" \ + --coordinator-public-key-file \ + "$rehearsal_root/public/coordinator-public-key.hex" \ + >"$RUNNER_TEMP/downloadable-tiny-definition.json" + python3 - "$RUNNER_TEMP/downloadable-tiny-definition.json" <<'PY' + import json + import sys + + with open(sys.argv[1], "rb") as handle: + result = json.load(handle) + inspection = result["definition_inspection"] + assert result["ok"] is True + assert inspection["mode"] == "rehearsal" + assert inspection["phase1_participants"] == [ + "participant-01", "participant-02", "participant-03" + ] + PY + test -f "$rehearsal_root/config/environment.json" + test -f "$rehearsal_root/keys/coordinator.ed25519.private.hex" + test "$(stat -c %a "$rehearsal_root/keys/coordinator.ed25519.private.hex")" = 600 + if "$ceremony_binary" rehearsal init \ + --created-at 2026-08-20T06:00:01Z \ + --out-dir "$rehearsal_root"; then + echo "rehearsal initializer overwrote an existing root" >&2 + exit 1 + fi + test -f "$rehearsal_root/public/ceremony.json" + + - name: Confirm rehearsal packages are unsigned + shell: bash + run: | + for release in \ + "$RUNNER_TEMP/mpc-rehearsal-a-parent/release" \ + "$RUNNER_TEMP/mpc-rehearsal-b-parent/release" + do + test "$(<"$release/build-mode.txt")" = rehearsal + test "$(<"$release/signed-tag.txt")" = none + test "$(<"$release/signed-tag-status.txt")" = not-required-for-rehearsal + test ! -e "$release/build-package-manifest.sig" + test ! -e "$release/build-package-manifest-public-key.hex" + done diff --git a/.github/workflows/release-proof-helper.yml b/.github/workflows/release-proof-helper.yml index 75878ca..37b8e35 100644 --- a/.github/workflows/release-proof-helper.yml +++ b/.github/workflows/release-proof-helper.yml @@ -12,18 +12,13 @@ on: required: true default: false type: boolean - signed_release: - description: Mark release notes as signed. Use only after Authenticode signatures are verified. - required: true - default: false - type: boolean - permissions: - contents: write + contents: read env: PROOF_HELPER_APP_DIR: apps/proof-helper-desktop PROOF_HELPER_TAURI_DIR: apps/proof-helper-desktop/src-tauri + PROOF_HELPER_RELEASE_TAG: ${{ inputs.tag }} WINDOWS_TARGET: x86_64-pc-windows-msvc WINDOWS_SIDECAR: proof-tool-x86_64-pc-windows-msvc.exe # Production origin the desktop helper pairs and opens by default. @@ -37,25 +32,32 @@ jobs: name: Release checks runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - name: Refuse reserved portable-helper tag shell: bash run: | - if [[ "${{ inputs.tag }}" == "proof-helper-v0.1.0" ]]; then + if [[ "$PROOF_HELPER_RELEASE_TAG" == "proof-helper-v0.1.0" ]]; then echo "::error::proof-helper-v0.1.0 is reserved for portable fixture-helper bundles. Use a new desktop tag." exit 1 fi + if [[ ! "$PROOF_HELPER_RELEASE_TAG" =~ ^proof-helper-desktop-v[0-9A-Za-z][0-9A-Za-z._-]*$ ]] || + ! git check-ref-format "refs/tags/$PROOF_HELPER_RELEASE_TAG"; then + echo "::error::tag must be a valid proof-helper-desktop-v... Git tag" + exit 1 + fi - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 with: version: 10.18.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm @@ -64,7 +66,7 @@ jobs: apps/ownership-proof-web/pnpm-lock.yaml apps/proof-helper-desktop/pnpm-lock.yaml - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable - name: Linux Tauri system dependencies run: | @@ -123,23 +125,25 @@ jobs: needs: checks runs-on: windows-latest steps: - - uses: actions/checkout@v4 + - uses: actions/checkout@11d5960a326750d5838078e36cf38b85af677262 # v4 + with: + persist-credentials: false - - uses: actions/setup-go@v5 + - uses: actions/setup-go@40f1582b2485089dde7abd97c1529aa768e1baff # v5 with: go-version-file: go.mod - - uses: pnpm/action-setup@v4 + - uses: pnpm/action-setup@f40ffcd9367d9f12939873eb1018b921a783ffaa # v4 with: version: 10.18.3 - - uses: actions/setup-node@v4 + - uses: actions/setup-node@49933ea5288caeca8642d1e84afbd3f7d6820020 # v4 with: node-version: 24 cache: pnpm cache-dependency-path: apps/proof-helper-desktop/pnpm-lock.yaml - - uses: dtolnay/rust-toolchain@stable + - uses: dtolnay/rust-toolchain@4360b52568e2003a75bf9bc1d59f33a8e3fc893c # stable with: targets: x86_64-pc-windows-msvc @@ -173,11 +177,9 @@ jobs: - name: Stage Windows release artifacts working-directory: apps/proof-helper-desktop shell: bash - env: - PROOF_HELPER_WINDOWS_SIGNED: ${{ inputs.signed_release && '1' || '0' }} run: | pnpm release:stage-windows -- \ - --tag "${{ inputs.tag }}" \ + --tag "$PROOF_HELPER_RELEASE_TAG" \ --bundle-dir "src-tauri/target/x86_64-pc-windows-msvc/release/bundle" \ --sidecar "src-tauri/binaries/proof-tool-x86_64-pc-windows-msvc.exe" \ --out-dir "../../dist/proof-helper-windows-x64" @@ -186,22 +188,18 @@ jobs: shell: bash run: | mkdir -p dist/proof-helper-windows-x64 - signing_status="Unsigned preview" - if [[ "${{ inputs.signed_release }}" == "true" ]]; then - signing_status="Signed release" - fi { - echo "Proof Helper Windows ${{ inputs.tag }}" + echo "Proof Helper Windows $PROOF_HELPER_RELEASE_TAG" echo echo "- Target: x86_64-pc-windows-msvc" - echo "- Signing status: ${signing_status}" + echo "- Signing status: Unsigned preview" echo "- Proof-assets descriptor: proof-assets-ownership-destination-v2-preprod-9fac96b-g3a" echo "- Proof-assets download route: public HTTPS (GitHub release asset), size and hashes pinned in the app descriptor" echo echo "Do not describe this as a general Windows end-user release until Authenticode signatures, a public proof-assets download route, and local Windows release-build validation are complete." } > dist/proof-helper-windows-x64/release-notes.md - - uses: actions/upload-artifact@v4 + - uses: actions/upload-artifact@ea165f8d65b6e75b540449e92b4886f43607fa02 # v4 with: name: proof-helper-windows-x64 path: dist/proof-helper-windows-x64/* @@ -210,17 +208,19 @@ jobs: name: Draft GitHub release needs: build-windows runs-on: ubuntu-22.04 + permissions: + contents: write steps: - - uses: actions/download-artifact@v4 + - uses: actions/download-artifact@d3f86a106a0bac45b974a628896c90dbdf5c8093 # v4 with: name: proof-helper-windows-x64 path: release-artifacts - name: Create GitHub Release - uses: softprops/action-gh-release@v2 + uses: softprops/action-gh-release@3bb12739c298aeb8a4eeaf626c5b8d85266b0e65 # v2 with: - tag_name: ${{ inputs.tag }} - name: Proof Helper Windows ${{ inputs.tag }} + tag_name: ${{ env.PROOF_HELPER_RELEASE_TAG }} + name: Proof Helper Windows ${{ env.PROOF_HELPER_RELEASE_TAG }} draft: ${{ inputs.publish_release != true }} prerelease: true body_path: release-artifacts/release-notes.md diff --git a/.gitignore b/.gitignore index c156cff..1a33ee9 100644 --- a/.gitignore +++ b/.gitignore @@ -71,6 +71,9 @@ experiments/wasm-prover/patches/* !experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch !experiments/wasm-prover/patches/uints-constant-fold.patch !experiments/wasm-prover/patches/computeh-parallel-transforms.patch +!experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch +!experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch +!experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch !experiments/wasm-prover/runtime/** !experiments/wasm-prover/fault/** !experiments/wasm-prover/scripts/** @@ -90,3 +93,7 @@ experiments/wasm-prover/web/* /docs/ux-review-landing-and-claim-flow.md /contracts/ownership-verifier/testdata/*-review.md .vercel/ + +# Local Go build outputs. +/mpc-ceremony +/workflowhelper diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 4666463..227ebc7 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -8,7 +8,7 @@ coherent). This file only covers the mechanics. ```bash pnpm install # repo root: installs Biome + lefthook, registers git hooks -bash scripts/bootstrap-vendor.sh # required: vendors gnark with the local ProveStream patch +bash scripts/bootstrap-vendor.sh # required: vendors gnark with the reviewed local patches ``` Never run plain `go mod vendor`; it drops the hand-applied patch. Use the diff --git a/apps/proof-helper-desktop/scripts/stage-windows-release.mjs b/apps/proof-helper-desktop/scripts/stage-windows-release.mjs index 8ea4d33..7754d2a 100644 --- a/apps/proof-helper-desktop/scripts/stage-windows-release.mjs +++ b/apps/proof-helper-desktop/scripts/stage-windows-release.mjs @@ -100,7 +100,10 @@ const manifest = { package_version: packageJson.version, tauri_config_version: tauriConfig.version, cargo_version: cargoVersion, - signed: process.env.PROOF_HELPER_WINDOWS_SIGNED === "1", + // Staging does not Authenticode-sign or verify artifacts. A future signed + // release path must derive this field from signature verification, never + // from operator input. + signed: false, sidecar: { name: path.basename(sidecarPath), target: TARGET, diff --git a/apps/proof-helper-desktop/scripts/stage-windows-release.test.mjs b/apps/proof-helper-desktop/scripts/stage-windows-release.test.mjs new file mode 100644 index 0000000..8900fbf --- /dev/null +++ b/apps/proof-helper-desktop/scripts/stage-windows-release.test.mjs @@ -0,0 +1,61 @@ +import { execFileSync } from "node:child_process"; +import { createHash } from "node:crypto"; +import fs from "node:fs"; +import fsp from "node:fs/promises"; +import path from "node:path"; +import { afterEach, expect, test } from "vitest"; + +const tempDirs = []; + +afterEach(() => { + for (const dir of tempDirs.splice(0)) fs.rmSync(dir, { recursive: true, force: true }); +}); + +test("stages Windows artifacts as unsigned even when the environment claims signing", async () => { + const root = await fsp.mkdtemp("/tmp/proof-helper-windows-stage-"); + tempDirs.push(root); + const app = path.join(root, "apps", "proof-helper-desktop"); + const bundle = path.join(root, "bundle"); + await fsp.mkdir(path.join(app, "src-tauri"), { recursive: true }); + await fsp.mkdir(bundle, { recursive: true }); + await fsp.writeFile(path.join(app, "package.json"), '{"version":"0.2.2"}\n'); + await fsp.writeFile( + path.join(app, "src-tauri", "tauri.conf.json"), + '{"version":"0.2.2","productName":"Proof Helper"}\n', + ); + await fsp.writeFile( + path.join(app, "src-tauri", "Cargo.toml"), + '[package]\nname = "proof-helper-desktop"\nversion = "0.2.2"\n', + ); + + const installer = path.join(bundle, "Proof Helper.msi"); + const sidecar = path.join(root, "proof-tool-x86_64-pc-windows-msvc.exe"); + const out = path.join(root, "out"); + await fsp.writeFile(installer, "installer-bytes"); + await fsp.writeFile(sidecar, "sidecar-bytes"); + + execFileSync( + process.execPath, + [ + path.resolve("scripts/stage-windows-release.mjs"), + "--repo-root", + root, + "--tag", + "proof-helper-desktop-v0.2.2-windows-preview.1", + "--bundle-dir", + bundle, + "--sidecar", + sidecar, + "--out-dir", + out, + ], + { env: { ...process.env, PROOF_HELPER_WINDOWS_SIGNED: "1" } }, + ); + + const artifact = "proof-helper_0.2.2_windows_x64.msi"; + const bytes = await fsp.readFile(path.join(out, artifact)); + const digest = createHash("sha256").update(bytes).digest("hex"); + expect(await fsp.readFile(path.join(out, `${artifact}.sha256`), "utf8")).toBe(`${digest} ${artifact}\n`); + const manifest = JSON.parse(await fsp.readFile(path.join(out, "proof-helper-windows-release-manifest.json"), "utf8")); + expect(manifest.signed).toBe(false); +}); diff --git a/apps/proof-helper-desktop/src/App.test.tsx b/apps/proof-helper-desktop/src/App.test.tsx index 0347f76..fc79645 100644 --- a/apps/proof-helper-desktop/src/App.test.tsx +++ b/apps/proof-helper-desktop/src/App.test.tsx @@ -170,7 +170,11 @@ describe("Proof Helper desktop app", () => { fireEvent.change(screen.getByLabelText("Bundle source"), { target: { value: "/tmp/source-bundle" } }); fireEvent.change(screen.getByLabelText("Manifest public key"), { target: { value: "ab".repeat(32) } }); fireEvent.change(screen.getByLabelText("Signature key id"), { target: { value: "test-signer" } }); - fireEvent.click(screen.getByRole("button", { name: /install local proof assets/i })); + // The one-shot auto-install sets busy="install", which disables this button. + // Without waiting, the click is dropped and activateKeyBundle is never called. + const install = screen.getByRole("button", { name: /install local proof assets/i }); + await waitFor(() => expect(install).toBeEnabled()); + fireEvent.click(install); await waitFor(() => expect(api.activateKeyBundle).toHaveBeenCalledOnce()); expect(api.activateKeyBundle).toHaveBeenCalledWith({ @@ -191,7 +195,9 @@ describe("Proof Helper desktop app", () => { await screen.findByRole("heading", { name: "Proof assets need setup" }); fireEvent.change(screen.getByLabelText("Bundle source"), { target: { value: "/tmp/source-bundle" } }); fireEvent.change(screen.getByLabelText("Manifest public key"), { target: { value: "cd".repeat(32) } }); - fireEvent.click(screen.getByRole("button", { name: /install local proof assets/i })); + const install = screen.getByRole("button", { name: /install local proof assets/i }); + await waitFor(() => expect(install).toBeEnabled()); + fireEvent.click(install); const cancel = await screen.findAllByRole("button", { name: /cancel install/i }); fireEvent.click(cancel[0]); diff --git a/cmd/mpc-ceremony/cli_test.go b/cmd/mpc-ceremony/cli_test.go index 8952b50..29174de 100644 --- a/cmd/mpc-ceremony/cli_test.go +++ b/cmd/mpc-ceremony/cli_test.go @@ -69,6 +69,17 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { args []string command Command }{ + { + name: "identity generate", + args: []string{ + "identity", "generate", + "--identity-id", "participant-03", + "--display-name", "Participant Three", + "--private-key-out", "private/participant-03.private.hex", + "--public-identity-out", "participant-03.identity.json", + }, + command: CommandIdentityGenerate, + }, { name: "init", args: []string{ @@ -334,6 +345,41 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { ), command: CommandDecisionVerify, }, + { + name: "ops prepare public witness receipt", + args: joinArgs( + []string{"ops", "prepare-public-witness-receipt"}, + ceremonyTrust, + []string{ + "--transcript-root", "transcript", + "--closure", "transcript/phase1/closure/record.json", + "--closure-signature", "transcript/phase1/closure/record.sig", + "--witness-enrollment", "ops/witness-enrollment.json", + "--witness-enrollment-signature", "ops/witness-enrollment.sig", + "--publication-location", "https://witness.example/phase1/closure", + "--observed-at", "2026-08-18T12:00:00Z", + "--out-dir", "ops/witness-export", + }, + ), + command: CommandOpsPreparePublicWitnessReceipt, + }, + { + name: "ops prepare mirror receipt", + args: joinArgs( + []string{"ops", "prepare-mirror-receipt"}, + ceremonyTrust, + []string{ + "--draft", "ops/mirror-draft.json", + "--transcript-root", "transcript", + "--chain", "transcript/phase1/chain-0001.json", + "--chain-signature", "transcript/phase1/chain-0001.sig", + "--mirror-enrollment", "ops/mirror-enrollment.json", + "--mirror-enrollment-signature", "ops/mirror-enrollment.sig", + "--out-dir", "ops/mirror-export", + }, + ), + command: CommandOpsPrepareMirrorReceipt, + }, { name: "ops export signing", args: joinArgs( @@ -377,6 +423,45 @@ func TestParseInvocationAcceptsRequiredCommandSurface(t *testing.T) { ), command: CommandOpsVerify, }, + { + name: "inspect definition", + args: joinArgs([]string{"inspect", "definition"}, ceremonyTrust), + command: CommandInspectDefinition, + }, + { + name: "inspect chain", + args: joinArgs( + []string{"inspect", "chain"}, + ceremonyTrust, + []string{ + "--transcript-root", "transcript", + "--chain", "transcript/phase1/chain-0001.json", + "--chain-signature", "transcript/phase1/chain-0001.sig", + }, + ), + command: CommandInspectChain, + }, + { + name: "inspect participant", + args: joinArgs( + []string{"inspect", "participant"}, + ceremonyTrust, + []string{"--participant-signing-key", "keys/participant-01.private.hex"}, + ), + command: CommandInspectParticipant, + }, + { + name: "inspect enrollment", + args: joinArgs( + []string{"inspect", "enrollment"}, + ceremonyTrust, + []string{ + "--enrollment", "ops/witness-enrollment.json", + "--enrollment-signature", "ops/witness-enrollment.sig", + }, + ), + command: CommandInspectEnrollment, + }, } for _, test := range tests { @@ -411,6 +496,63 @@ func TestParseInvocationRejectsMissingExplicitPaths(t *testing.T) { } } +func TestBrokerlessCommandsRequireSecurityCriticalInputs(t *testing.T) { + t.Parallel() + tests := []struct { + name string + args []string + want string + }{ + { + name: "participant key", + args: []string{ + "inspect", "participant", + "--ceremony", "ceremony.json", + "--ceremony-signature", "ceremony.sig", + "--coordinator-public-key-file", "coordinator.pub", + }, + want: "--participant-signing-key", + }, + { + name: "enrollment signature", + args: []string{ + "inspect", "enrollment", + "--ceremony", "ceremony.json", + "--ceremony-signature", "ceremony.sig", + "--coordinator-public-key-file", "coordinator.pub", + "--enrollment", "witness.json", + }, + want: "--enrollment-signature", + }, + { + name: "witness observation", + args: []string{ + "ops", "prepare-public-witness-receipt", + "--ceremony", "ceremony.json", + "--ceremony-signature", "ceremony.sig", + "--coordinator-public-key-file", "coordinator.pub", + "--transcript-root", "transcript", + "--closure", "closure.json", + "--closure-signature", "closure.sig", + "--witness-enrollment", "witness.json", + "--witness-enrollment-signature", "witness.sig", + "--publication-location", "https://witness.example/closure", + "--out-dir", "output", + }, + want: "--observed-at", + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + t.Parallel() + _, err := parseInvocation(test.args) + if err == nil || !strings.Contains(err.Error(), test.want) { + t.Fatalf("error = %v, want missing %s", err, test.want) + } + }) + } +} + func TestParseInvocationRejectsStreamsURLsAndForce(t *testing.T) { t.Parallel() @@ -793,6 +935,55 @@ func TestRunCLIErrorOutputRedactsCallerControlledValues(t *testing.T) { } } +func TestDiagnosticRedactionRecognizesInspectionAndReceiptCommands(t *testing.T) { + t.Parallel() + + for _, test := range []struct { + name string + args []string + commandIndex int + valueIndex int + }{ + { + name: "participant inspection", + args: []string{"--format=json", "inspect", "participant", "--participant-signing-key", "secret.key"}, + commandIndex: 1, + valueIndex: 4, + }, + { + name: "enrollment inspection", + args: []string{"inspect", "enrollment", "--enrollment", "enrollment.json"}, + commandIndex: 0, + valueIndex: 3, + }, + { + name: "public witness receipt", + args: []string{"ops", "prepare-public-witness-receipt", "--publication-location", "https://private.example/closure"}, + commandIndex: 0, + valueIndex: 3, + }, + { + name: "rehearsal initializer", + args: []string{"rehearsal", "init", "--out-dir", "private-rehearsal"}, + commandIndex: 0, + valueIndex: 3, + }, + } { + t.Run(test.name, func(t *testing.T) { + safe := identifyCLICommandArguments(test.args) + if _, ok := safe[test.commandIndex]; !ok { + t.Fatal("top-level command is not recognized as diagnostic-safe") + } + if _, ok := safe[test.commandIndex+1]; !ok { + t.Fatal("subcommand is not recognized as diagnostic-safe") + } + if _, ok := safe[test.valueIndex]; ok { + t.Fatal("caller-controlled flag value is incorrectly diagnostic-safe") + } + }) + } +} + func TestRunCLIRejectsHelpOutputFailure(t *testing.T) { t.Parallel() diff --git a/cmd/mpc-ceremony/decision_test.go b/cmd/mpc-ceremony/decision_test.go index 5494212..7735378 100644 --- a/cmd/mpc-ceremony/decision_test.go +++ b/cmd/mpc-ceremony/decision_test.go @@ -207,12 +207,19 @@ func decisionSignFixture(t *testing.T) (mpcceremony.CeremonyDefinition, []byte, CreatedAt: "2026-07-23T12:00:00Z", SessionNonceHex: strings.Repeat("5a", 32), Circuit: mpcceremony.CircuitBinding{ - KeyVersion: mpcceremony.KeyVersionDestinationV2, - CircuitID: mpcceremony.CircuitIDDestinationV2, - Curve: mpcceremony.CurveBLS12381, - Backend: mpcceremony.BackendGroth16, - R1CS: decisionArtifact("circuit.ccs", "r1cs").Artifact, - Constraints: 1_789_750, + KeyVersion: mpcceremony.KeyVersionDestinationV2, + CircuitID: mpcceremony.CircuitIDDestinationV2, + Curve: mpcceremony.CurveBLS12381, + Backend: mpcceremony.BackendGroth16, + R1CS: mpcceremony.ArtifactRef{ + Name: "circuit.ccs", + Digest: mpcceremony.Digest{ + SHA256: mpcceremony.CanonicalDestinationV2SHA256, + Blake2b256: mpcceremony.CanonicalDestinationV2Blake2b256, + Size: mpcceremony.CanonicalDestinationV2Size, + }, + }, + Constraints: mpcceremony.CanonicalDestinationV2Constraints, InternalVariables: 3, SecretVariables: 2, PublicVariables: 1, diff --git a/cmd/mpc-ceremony/executor.go b/cmd/mpc-ceremony/executor.go index f190194..7a09ae0 100644 --- a/cmd/mpc-ceremony/executor.go +++ b/cmd/mpc-ceremony/executor.go @@ -31,6 +31,12 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com switch invocation.Command { case CommandInit: return executeInit(invocation.Options.(InitOptions)) + case CommandIdentityGenerate: + return executeIdentityGenerate(invocation.Options.(IdentityGenerateOptions)) + case CommandRehearsalInit: + return executeRehearsalInit(invocation.Options.(RehearsalInitOptions)) + case CommandInspect: + return executeInspect(invocation.Options.(InspectOptions)) case CommandPhase1Contribute: return executeContribution(mpcceremony.Phase1, invocation.Options.(ContributeOptions)) case CommandPhase1Erasure: @@ -65,6 +71,10 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeReleaseSign(invocation.Options.(ReleaseSignOptions)) case CommandReleaseVerify: return executeReleaseVerify(invocation.Options.(ReleaseVerifyOptions)) + case CommandOpsPreparePublicWitnessReceipt: + return executeOpsPreparePublicWitnessReceipt(invocation.Options.(OpsPreparePublicWitnessReceiptOptions)) + case CommandOpsPrepareMirrorReceipt: + return executeOpsPrepareMirrorReceipt(invocation.Options.(OpsPrepareMirrorReceiptOptions)) case CommandOpsExportSigning: return executeOpsExportSigning(invocation.Options.(OpsExportSigningOptions)) case CommandOpsImportSig: @@ -77,6 +87,14 @@ func (workflowExecutor) Execute(ctx context.Context, invocation Invocation) (Com return executeDecisionSign(invocation.Options.(DecisionSignOptions)) case CommandDecisionVerify: return executeDecisionVerify(invocation.Options.(DecisionVerifyOptions)) + case CommandInspectDefinition: + return executeInspectDefinition(invocation.Options.(InspectDefinitionOptions)) + case CommandInspectChain: + return executeInspectChain(invocation.Options.(InspectChainOptions)) + case CommandInspectParticipant: + return executeInspectParticipant(invocation.Options.(InspectParticipantOptions)) + case CommandInspectEnrollment: + return executeInspectEnrollment(invocation.Options.(InspectEnrollmentOptions)) default: return CommandResult{}, fmt.Errorf("%w: %s", errExecutorNotWired, invocation.Command) } @@ -106,10 +124,18 @@ 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 } + if options.Mode == mpcceremony.ModeProduction { + // Fail before allocating and writing the large Phase 1 genesis. The same + // invariant is also enforced by CeremonyDefinition validation so no + // consumer can bypass it by creating or loading a definition elsewhere. + if err := mpcceremony.ValidateCanonicalDestinationV2(circuit.Binding); err != nil { + return CommandResult{}, err + } + } result, err := mpcceremony.InitializeCeremonyFiles(mpcceremony.InitFilesOptions{ RootDir: options.OutDir, Circuit: circuit, @@ -297,6 +323,7 @@ func executeClose(phase mpcceremony.Phase, options CloseOptions) (CommandResult, Phase1SealSignaturePath: options.Phase1SealSignaturePath, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, BeaconRound: options.BeaconRound, + BeaconRoundLeadSeconds: uint32(options.BeaconRoundLeadSeconds), }) if err != nil { return CommandResult{}, err @@ -371,6 +398,7 @@ func executePhase1Seal(options Phase1SealOptions) (CommandResult, error) { BeaconSignaturePath: options.BeaconSignaturePath, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, OutputDir: options.OutDir, + Progress: replayProgressReporter(), }) if err != nil { return CommandResult{}, err @@ -408,6 +436,7 @@ func executePhase2Init(options Phase2InitOptions) (CommandResult, error) { Phase1SealSignaturePath: options.Phase1SealSignaturePath, CoordinatorPrivateKeyPath: options.CoordinatorSigningKey, OutputDir: options.OutDir, + Progress: stageProgressReporter(), }) if err != nil { return CommandResult{}, err @@ -437,7 +466,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 } @@ -486,7 +515,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 } @@ -531,7 +560,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 } @@ -672,6 +701,37 @@ func transcriptPaths(root, chain, signature string) mpcceremony.PhaseTranscriptP RootDir: root, ChainPath: chain, ChainSignaturePath: signature, + Progress: replayProgressReporter(), + } +} + +// replayProgressReporter renders replay progress to stderr. A K=21 replay runs +// for hours; without this an operator cannot tell running from hung, and cannot +// measure how long a close takes in order to choose a beacon round far enough +// ahead. Output goes to stderr because stdout carries the result contract, and +// it reports only a phase, an index and a count — never a path or key material. +func replayProgressReporter() mpcceremony.ReplayProgress { + start := time.Now() + return func(phase mpcceremony.Phase, index, total int) { + fmt.Fprintf( + os.Stderr, + "replaying %s contribution %d/%d (%s elapsed)\n", + phase, index, total, time.Since(start).Round(time.Second), + ) + } +} + +// stageProgressReporter renders stage entry to stderr. Phase 2 initialization +// has no contributions to count and its expensive stage is a single call into +// gnark, so naming the running stage is the honest signal available. +func stageProgressReporter() mpcceremony.StageProgress { + start := time.Now() + return func(stage string, index, total int) { + fmt.Fprintf( + os.Stderr, + "stage %d/%d: %s (%s elapsed)\n", + index, total, stage, time.Since(start).Round(time.Second), + ) } } @@ -790,3 +850,77 @@ func loadOperationalCircuit(paths mpcceremony.TrustPaths, transcriptRoot string) r1csPath := filepath.Join(transcriptRoot, filepath.FromSlash(trusted.Definition.Circuit.R1CS.Name)) return mpcceremony.ReadR1CSFile(r1csPath, trusted.Definition.Circuit) } + +func executeInspect(options InspectOptions) (CommandResult, error) { + result, err := mpcceremony.InspectCeremony(mpcceremony.InspectCeremonyOptions{ + Trust: trustPaths( + options.CeremonyPath, + options.CeremonySignaturePath, + options.CoordinatorPublicKeyFile, + ), + TranscriptRoot: options.TranscriptDir, + Full: options.Full, + }) + if err != nil { + return CommandResult{}, err + } + outputs := map[string]string{ + "mode": result.Mode, + "depth": result.Depth, + } + for _, phase := range result.Phases { + prefix := string(phase.Phase) + if !phase.Started { + outputs[prefix+"_status"] = "not started" + continue + } + status := "accepting contributions" + switch { + case phase.Sealed: + status = "sealed" + case phase.BeaconRecorded: + status = "beacon recorded" + case phase.Closed: + status = "closed" + case phase.ContributionsComplete: + status = "contributions complete" + } + outputs[prefix+"_status"] = status + outputs[prefix+"_chain"] = phase.ChainFile + outputs[prefix+"_accepted"] = fmt.Sprintf("%d of %d scheduled", phase.AcceptedCount, phase.ScheduledTotal) + outputs[prefix+"_head_record_id"] = phase.HeadRecordID + outputs[prefix+"_head_payload"] = phase.HeadPayload + if phase.NextParticipantID != "" { + outputs[prefix+"_next_contribution"] = fmt.Sprintf( + "index %d by %s", phase.NextIndex, phase.NextParticipantID, + ) + } + if len(phase.MissingArtifacts) == 0 { + outputs[prefix+"_artifacts"] = "all referenced artifacts present" + } else { + outputs[prefix+"_artifacts"] = "MISSING: " + strings.Join(phase.MissingArtifacts, "; ") + } + } + return CommandResult{ + CeremonyID: result.CeremonyID, + Summary: fmt.Sprintf( + "inspected ceremony at %s depth; inspection is read-only and authorizes nothing", + result.Depth, + ), + 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/identity.go b/cmd/mpc-ceremony/identity.go new file mode 100644 index 0000000..1b200de --- /dev/null +++ b/cmd/mpc-ceremony/identity.go @@ -0,0 +1,132 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "crypto/ed25519" + "crypto/rand" + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + + "proof-tool/internal/mpcceremony" +) + +const generatedIdentityKeyIDPrefix = "ed25519:" + +func executeIdentityGenerate(options IdentityGenerateOptions) (CommandResult, error) { + privateTarget, err := resolvedFreshTarget(options.PrivateKeyOut) + if err != nil { + return CommandResult{}, fmt.Errorf("private key output: %w", err) + } + publicTarget, err := resolvedFreshTarget(options.PublicIdentityOut) + if err != nil { + return CommandResult{}, fmt.Errorf("public identity output: %w", err) + } + if privateTarget == publicTarget { + return CommandResult{}, errors.New("private and public output paths must be distinct") + } + if err := requireFreshTarget(options.PrivateKeyOut); err != nil { + return CommandResult{}, fmt.Errorf("private key output: %w", err) + } + if err := requireFreshTarget(options.PublicIdentityOut); err != nil { + return CommandResult{}, fmt.Errorf("public identity output: %w", err) + } + + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return CommandResult{}, fmt.Errorf("generate Ed25519 key from operating-system CSPRNG: %w", err) + } + defer zeroBytes(privateKey) + + publicKeyDigest := sha256.Sum256(publicKey) + identity, err := mpcceremony.NewIdentity( + options.IdentityID, + options.DisplayName, + generatedIdentityKeyIDPrefix+hex.EncodeToString(publicKeyDigest[:]), + publicKey, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("create public ceremony identity: %w", err) + } + publicIdentity, err := mpcceremony.MarshalCanonical(identity) + if err != nil { + return CommandResult{}, fmt.Errorf("encode public ceremony identity: %w", err) + } + + seed := privateKey.Seed() + defer zeroBytes(seed) + privateSeedHex := make([]byte, hex.EncodedLen(len(seed))+1) + hex.Encode(privateSeedHex, seed) + privateSeedHex[len(privateSeedHex)-1] = '\n' + defer zeroBytes(privateSeedHex) + + // Write the non-secret artifact first. A late private-file collision can + // leave an unusable public identity, but it can never strand private key + // material or cause an existing file to be overwritten. + if err := writeFreshOperationalFile(options.PublicIdentityOut, publicIdentity, 0o644); err != nil { + return CommandResult{}, err + } + if err := syncDirectory(filepath.Dir(options.PublicIdentityOut)); err != nil { + return CommandResult{}, fmt.Errorf("sync public identity directory: %w", err) + } + if err := writeFreshOperationalFile(options.PrivateKeyOut, privateSeedHex, 0o600); err != nil { + return CommandResult{}, fmt.Errorf( + "write private key (public identity was created but must not be enrolled): %w", + err, + ) + } + if err := syncDirectory(filepath.Dir(options.PrivateKeyOut)); err != nil { + return CommandResult{}, fmt.Errorf("sync private key directory: %w", err) + } + + return CommandResult{ + Identity: &identity, + Outputs: map[string]string{ + "private_key_SECRET": options.PrivateKeyOut, + "public_identity": options.PublicIdentityOut, + }, + Summary: "generated Ed25519 ceremony identity; keep private_key_SECRET local and share only public_identity", + }, nil +} + +func resolvedFreshTarget(path string) (string, error) { + absolute, err := filepath.Abs(path) + if err != nil { + return "", err + } + parent, err := filepath.EvalSymlinks(filepath.Dir(absolute)) + if err != nil { + return "", fmt.Errorf("resolve parent directory: %w", err) + } + info, err := os.Stat(parent) + if err != nil { + return "", fmt.Errorf("inspect parent directory: %w", err) + } + if !info.IsDir() { + return "", errors.New("parent is not a directory") + } + return filepath.Join(parent, filepath.Base(absolute)), nil +} + +func requireFreshTarget(path string) error { + _, err := os.Lstat(path) + switch { + case err == nil: + return errors.New("output already exists") + case errors.Is(err, os.ErrNotExist): + return nil + default: + return err + } +} + +func zeroBytes(value []byte) { + for index := range value { + value[index] = 0 + } +} diff --git a/cmd/mpc-ceremony/identity_test.go b/cmd/mpc-ceremony/identity_test.go new file mode 100644 index 0000000..8f74f2a --- /dev/null +++ b/cmd/mpc-ceremony/identity_test.go @@ -0,0 +1,215 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + + "proof-tool/internal/keybundle" + "proof-tool/internal/mpcceremony" +) + +func TestIdentityGenerateCreatesCompatibleProtectedKeyAndCanonicalPublicIdentity(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "participant-03.private.hex") + publicPath := filepath.Join(root, "participant-03.identity.json") + args := []string{ + "identity", "generate", + "--identity-id", "participant-03", + "--display-name", "Participant Three", + "--private-key-out", privatePath, + "--public-identity-out", publicPath, + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("identity generate exit = %d, stderr = %q", code, stderr.String()) + } + if stderr.Len() != 0 { + t.Fatalf("stderr = %q", stderr.String()) + } + + privateInfo, err := os.Lstat(privatePath) + if err != nil { + t.Fatal(err) + } + if !privateInfo.Mode().IsRegular() { + t.Fatalf("private output mode = %s, want regular file", privateInfo.Mode()) + } + if runtime.GOOS != "windows" && privateInfo.Mode().Perm() != 0o600 { + t.Fatalf("private output permissions = %o, want 600", privateInfo.Mode().Perm()) + } + + publicIdentityBytes, err := os.ReadFile(publicPath) + if err != nil { + t.Fatal(err) + } + var identity mpcceremony.Identity + if err := mpcceremony.UnmarshalCanonical(publicIdentityBytes, &identity); err != nil { + t.Fatalf("public identity is not canonical: %v", err) + } + if identity.ID != "participant-03" || identity.DisplayName != "Participant Three" { + t.Fatalf("identity = %+v", identity) + } + wantKeyID := generatedIdentityKeyIDPrefix + strings.TrimPrefix(identity.PublicKeyFingerprint, "sha256:") + if identity.KeyID != wantKeyID { + t.Fatalf("key id = %q, want %q", identity.KeyID, wantKeyID) + } + + privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(privatePath) + if err != nil { + t.Fatalf("generated key is not proof-tool-compatible: %v", err) + } + defer zeroBytes(privateKey) + if got := hex.EncodeToString(publicKey); got != identity.Ed25519PublicKeyHex { + t.Fatalf("derived public key = %q, identity has %q", got, identity.Ed25519PublicKeyHex) + } + seedHexBytes, err := os.ReadFile(privatePath) + if err != nil { + t.Fatal(err) + } + seedHex := strings.TrimSpace(string(seedHexBytes)) + zeroBytes(seedHexBytes) + if strings.Contains(stdout.String(), seedHex) { + t.Fatal("human command output disclosed the private seed") + } + for _, want := range []string{ + "key_id: " + identity.KeyID, + "public_key_fingerprint: " + identity.PublicKeyFingerprint, + "private_key_SECRET: " + privatePath, + "public_identity: " + publicPath, + } { + if !strings.Contains(stdout.String(), want) { + t.Errorf("stdout %q does not contain %q", stdout.String(), want) + } + } +} + +func TestIdentityGenerateJSONOutputContainsPublicMetadataButNotPrivateKey(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "auditor-01.private.hex") + publicPath := filepath.Join(root, "auditor-01.identity.json") + var stdout bytes.Buffer + var stderr bytes.Buffer + if code := runCLI(context.Background(), []string{ + "--format", "json", + "identity", "generate", + "--identity-id", "auditor-01", + "--display-name", "Independent Auditor One", + "--private-key-out", privatePath, + "--public-identity-out", publicPath, + }, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("identity generate exit = %d, stderr = %q", code, stderr.String()) + } + + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatalf("decode JSON result: %v", err) + } + if !result.OK || result.Command != CommandIdentityGenerate || result.Identity == nil { + t.Fatalf("result = %+v", result) + } + if result.Identity.ID != "auditor-01" || result.Identity.KeyID == "" { + t.Fatalf("public identity result = %+v", result.Identity) + } + privateBytes, err := os.ReadFile(privatePath) + if err != nil { + t.Fatal(err) + } + privateHex := strings.TrimSpace(string(privateBytes)) + zeroBytes(privateBytes) + if strings.Contains(stdout.String(), privateHex) { + t.Fatal("JSON command output disclosed the private seed") + } +} + +func TestIdentityGenerateDoesNotOverwriteOrCreatePartialSecretOutput(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "identity.private.hex") + publicPath := filepath.Join(root, "identity.json") + existing := []byte("already enrolled") + if err := os.WriteFile(publicPath, existing, 0o600); err != nil { + t.Fatal(err) + } + + var stdout bytes.Buffer + var stderr bytes.Buffer + code := runCLI(context.Background(), []string{ + "identity", "generate", + "--identity-id", "participant-03", + "--display-name", "Participant Three", + "--private-key-out", privatePath, + "--public-identity-out", publicPath, + }, &stdout, &stderr, workflowExecutor{}) + if code == 0 { + t.Fatal("identity generate overwrote an existing output") + } + got, err := os.ReadFile(publicPath) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, existing) { + t.Fatalf("existing public output changed to %q", got) + } + if _, err := os.Lstat(privatePath); !os.IsNotExist(err) { + t.Fatalf("private output exists after preflight failure: %v", err) + } +} + +func TestIdentityGenerateRejectsSameResolvedOutput(t *testing.T) { + root := t.TempDir() + realDir := filepath.Join(root, "real") + if err := os.Mkdir(realDir, 0o700); err != nil { + t.Fatal(err) + } + aliasDir := filepath.Join(root, "alias") + if err := os.Symlink(realDir, aliasDir); err != nil { + if runtime.GOOS == "windows" { + t.Skipf("symlink unavailable: %v", err) + } + t.Fatal(err) + } + + _, err := executeIdentityGenerate(IdentityGenerateOptions{ + IdentityID: "participant-03", + DisplayName: "Participant Three", + PrivateKeyOut: filepath.Join(realDir, "same"), + PublicIdentityOut: filepath.Join(aliasDir, "same"), + }) + if err == nil || !strings.Contains(err.Error(), "must be distinct") { + t.Fatalf("same resolved output error = %v", err) + } + if _, err := os.Lstat(filepath.Join(realDir, "same")); !os.IsNotExist(err) { + t.Fatalf("same output exists after rejection: %v", err) + } +} + +func TestIdentityGenerateValidatesIdentityBeforeWriting(t *testing.T) { + root := t.TempDir() + privatePath := filepath.Join(root, "identity.private.hex") + publicPath := filepath.Join(root, "identity.json") + _, err := executeIdentityGenerate(IdentityGenerateOptions{ + IdentityID: "Participant-03", + DisplayName: "Participant Three", + PrivateKeyOut: privatePath, + PublicIdentityOut: publicPath, + }) + if err == nil { + t.Fatal("invalid identity id was accepted") + } + for _, path := range []string{privatePath, publicPath} { + if _, statErr := os.Lstat(path); !os.IsNotExist(statErr) { + t.Fatalf("output %s exists after validation failure: %v", path, statErr) + } + } +} diff --git a/cmd/mpc-ceremony/inspect.go b/cmd/mpc-ceremony/inspect.go new file mode 100644 index 0000000..60ad5a8 --- /dev/null +++ b/cmd/mpc-ceremony/inspect.go @@ -0,0 +1,181 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "fmt" + + "proof-tool/internal/mpcceremony" +) + +const ( + definitionInspectionSchema = "proof-tool-mpc-definition-inspection-v1" + chainInspectionSchema = "proof-tool-mpc-chain-inspection-v1" + participantInspectionSchema = "proof-tool-mpc-participant-inspection-v1" + enrollmentInspectionSchema = "proof-tool-mpc-enrollment-inspection-v1" +) + +func executeInspectDefinition(options InspectDefinitionOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options) + if err != nil { + return CommandResult{}, err + } + inspection := inspectDefinition(trusted.Definition) + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: "authenticated ceremony definition", + DefinitionInspection: &inspection, + }, nil +} + +func executeInspectChain(options InspectChainOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + chain, err := mpcceremony.LoadSignedChain(trusted, mpcceremony.PhaseTranscriptPaths{ + RootDir: options.TranscriptRoot, + ChainPath: options.ChainPath, + ChainSignaturePath: options.ChainSignaturePath, + }) + if err != nil { + return CommandResult{}, err + } + inspection := inspectChain(chain) + return CommandResult{ + CeremonyID: chain.CeremonyID, + Phase: string(chain.Phase), + Sequence: len(chain.Records), + Summary: fmt.Sprintf("authenticated %s chain with %d accepted contributions", chain.Phase, len(chain.Records)), + ChainInspection: &inspection, + }, nil +} + +func executeInspectParticipant(options InspectParticipantOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + match, err := mpcceremony.InspectParticipantSigningKey( + trusted.Definition, + options.ParticipantSigningKey, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("participant signing key: %w", err) + } + inspection := ParticipantInspection{ + Schema: participantInspectionSchema, + CeremonyID: trusted.Definition.CeremonyID, + ParticipantID: match.ParticipantID, + KeyID: match.KeyID, + PublicKeyFingerprint: match.PublicKeyFingerprint, + Phase1Position: cloneUint8Pointer(match.Phase1Position), + Phase2Position: cloneUint8Pointer(match.Phase2Position), + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: "matched existing signing key to authenticated participant roster", + ParticipantInspection: &inspection, + }, nil +} + +func executeInspectEnrollment(options InspectEnrollmentOptions) (CommandResult, error) { + trusted, err := loadInspectionCeremony(options.InspectDefinitionOptions) + if err != nil { + return CommandResult{}, err + } + recordBytes, err := readRegularOperationalFile(options.EnrollmentPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + signatureBytes, err := readRegularOperationalFile(options.EnrollmentSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + enrollment, err := mpcceremony.VerifyEnrollmentProofOfPossession( + trusted.Definition, + definitionBytes, + recordBytes, + signatureBytes, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("enrollment proof of possession: %w", err) + } + inspection := EnrollmentInspection{ + Schema: enrollmentInspectionSchema, + CeremonyID: enrollment.CeremonyID, + Identity: enrollment.Identity, + Role: enrollment.Role, + RoleIndex: enrollment.RoleIndex, + EnrolledAt: enrollment.EnrolledAt, + IndependenceDisclosure: enrollment.IndependenceDisclosure, + } + return CommandResult{ + CeremonyID: enrollment.CeremonyID, + Summary: "authenticated operational enrollment and proof of possession", + EnrollmentInspection: &inspection, + }, nil +} + +func cloneUint8Pointer(value *uint8) *uint8 { + if value == nil { + return nil + } + copy := *value + return © +} + +func loadInspectionCeremony(options InspectDefinitionOptions) (*mpcceremony.TrustedCeremony, error) { + return mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }) +} + +func inspectDefinition(definition mpcceremony.CeremonyDefinition) DefinitionInspection { + return DefinitionInspection{ + Schema: definitionInspectionSchema, + CeremonyID: definition.CeremonyID, + Mode: definition.Mode, + Phase1Participants: append([]string(nil), definition.Phase1Policy.Participants...), + Phase2Participants: append([]string(nil), definition.Phase2Policy.Participants...), + R1CS: definition.Circuit.R1CS, + } +} + +func inspectChain(chain mpcceremony.Chain) ChainInspection { + artifacts := make([]mpcceremony.ArtifactRef, 0, 1+6*len(chain.Records)) + artifacts = append(artifacts, chain.Genesis) + records := make([]ChainRecordInspection, 0, len(chain.Records)) + for _, record := range chain.Records { + recordArtifacts := []mpcceremony.ArtifactRef{ + record.OutputPayload, + record.Attestation, + record.AttestationSignature, + record.Erasure, + record.ErasureSignature, + record.Verification, + } + artifacts = append(artifacts, recordArtifacts...) + records = append(records, ChainRecordInspection{ + Index: record.Index, + RecordID: record.RecordID, + ParticipantID: record.ParticipantID, + Artifacts: recordArtifacts, + }) + } + return ChainInspection{ + Schema: chainInspectionSchema, + CeremonyID: chain.CeremonyID, + Phase: chain.Phase, + AcceptedCount: len(chain.Records), + Artifacts: artifacts, + Records: records, + } +} diff --git a/cmd/mpc-ceremony/inspect_test.go b/cmd/mpc-ceremony/inspect_test.go new file mode 100644 index 0000000..c80913e --- /dev/null +++ b/cmd/mpc-ceremony/inspect_test.go @@ -0,0 +1,368 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "context" + "crypto/ed25519" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "proof-tool/internal/mpcceremony" +) + +func TestInspectCommandsAuthenticateSignedDefinitionAndChain(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + definitionBytes, definitionSignature, err := mpcceremony.SignRecord( + definition, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + phaseID, err := mpcceremony.ComputePhaseID( + definition.CeremonyID, + mpcceremony.Phase1, + definition.Phase1Genesis, + "", + ) + if err != nil { + t.Fatal(err) + } + chain, err := mpcceremony.NewChain( + definition.CeremonyID, + mpcceremony.Phase1, + phaseID, + definition.Phase1Genesis, + ) + if err != nil { + t.Fatal(err) + } + chainBytes, chainSignature, err := mpcceremony.SignRecord( + chain, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + + ceremonyPath := filepath.Join(root, "ceremony.json") + ceremonySignaturePath := filepath.Join(root, "ceremony.sig") + coordinatorPublicKeyPath := filepath.Join(root, "coordinator-public-key.hex") + chainPath := filepath.Join(root, "phase1", "chain-0000.json") + chainSignaturePath := filepath.Join(root, "phase1", "chain-0000.sig") + if err := os.MkdirAll(filepath.Dir(chainPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, ceremonyPath, definitionBytes, 0o600) + writeDecisionTestFile(t, ceremonySignaturePath, definitionSignature, 0o600) + writeDecisionTestFile(t, coordinatorPublicKeyPath, []byte(definition.Coordinator.Ed25519PublicKeyHex+"\n"), 0o600) + writeDecisionTestFile(t, chainPath, chainBytes, 0o600) + writeDecisionTestFile(t, chainSignaturePath, chainSignature, 0o600) + + trustArgs := []string{ + "--ceremony", ceremonyPath, + "--ceremony-signature", ceremonySignaturePath, + "--coordinator-public-key-file", coordinatorPublicKeyPath, + } + tests := []struct { + name string + args []string + command Command + check func(CommandResult) bool + }{ + { + name: "definition", + args: append([]string{"--format", "json", "inspect", "definition"}, trustArgs...), + command: CommandInspectDefinition, + check: func(result CommandResult) bool { + return result.DefinitionInspection != nil && + result.DefinitionInspection.CeremonyID == definition.CeremonyID && + reflect.DeepEqual(result.DefinitionInspection.Phase1Participants, definition.Phase1Policy.Participants) + }, + }, + { + name: "chain", + args: append( + append([]string{"--format", "json", "inspect", "chain"}, trustArgs...), + "--transcript-root", root, + "--chain", chainPath, + "--chain-signature", chainSignaturePath, + ), + command: CommandInspectChain, + check: func(result CommandResult) bool { + return result.ChainInspection != nil && + result.ChainInspection.CeremonyID == definition.CeremonyID && + result.ChainInspection.AcceptedCount == 0 && + len(result.ChainInspection.Artifacts) == 1 + }, + }, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), test.args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + if !result.OK || result.Command != test.command || !test.check(result) { + t.Fatalf("result = %#v", result) + } + }) + } + + writeDecisionTestFile(t, chainPath, append(chainBytes, '\n'), 0o600) + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), tests[1].args, &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatalf("tampered chain was accepted: stdout = %q", stdout.String()) + } +} + +func TestInspectParticipantMatchesRosterPositionsWithoutExposingPrivateKey(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + definition.Mode = mpcceremony.ModeRehearsal + definition.Phase2Policy.Participants = []string{"participant-01", "participant-03"} + definition.Phase2Policy.Minimum = 2 + var err error + definition, err = mpcceremony.FinalizeCeremonyDefinition(definition) + if err != nil { + t.Fatal(err) + } + trustArgs := writeInspectionTrustFixture(t, root, definition, coordinatorKey) + participantKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{0x12}, ed25519.SeedSize)) + seedHex := hex.EncodeToString(participantKey.Seed()) + keyPath := filepath.Join(root, "participant-02.private.hex") + writeDecisionTestFile(t, keyPath, []byte(seedHex+"\n"), 0o600) + + args := append( + append([]string{"--format", "json", "inspect", "participant"}, trustArgs...), + "--participant-signing-key", keyPath, + ) + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + if strings.Contains(stdout.String(), seedHex) || strings.Contains(stdout.String(), keyPath) { + t.Fatal("participant inspection output exposed private key material or its path") + } + if !strings.Contains(stdout.String(), `"phase2_position":null`) { + t.Fatalf("absent phase position was not explicit null: %s", stdout.String()) + } + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + inspection := result.ParticipantInspection + if inspection == nil || inspection.Schema != participantInspectionSchema || + inspection.ParticipantID != "participant-02" || + inspection.KeyID != definition.Roster[1].Identity.KeyID || + inspection.PublicKeyFingerprint != definition.Roster[1].Identity.PublicKeyFingerprint || + inspection.Phase1Position == nil || *inspection.Phase1Position != 2 || + inspection.Phase2Position != nil { + t.Fatalf("participant inspection = %#v", inspection) + } +} + +func TestInspectEnrollmentAuthenticatesWitnessAndMirrorProofs(t *testing.T) { + for _, test := range []struct { + role mpcceremony.EnrollmentRole + id string + fill byte + }{ + {role: mpcceremony.EnrollmentPublicWitness, id: "public-witness-01", fill: 0x91}, + {role: mpcceremony.EnrollmentMirrorOperator, id: "mirror-operator-01", fill: 0xa1}, + } { + t.Run(string(test.role), func(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + trustArgs := writeInspectionTrustFixture(t, root, definition, coordinatorKey) + record, recordBytes, signatureBytes, _ := commandSignedExternalEnrollment( + t, definition, test.role, test.id, test.fill, + ) + recordPath := filepath.Join(root, test.id+".json") + signaturePath := filepath.Join(root, test.id+".sig") + writeDecisionTestFile(t, recordPath, recordBytes, 0o600) + writeDecisionTestFile(t, signaturePath, signatureBytes, 0o600) + args := append( + append([]string{"--format", "json", "inspect", "enrollment"}, trustArgs...), + "--enrollment", recordPath, + "--enrollment-signature", signaturePath, + ) + var stdout, stderr bytes.Buffer + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code != 0 { + t.Fatalf("exit = %d, stdout = %q, stderr = %q", code, stdout.String(), stderr.String()) + } + var result CommandResult + if err := json.Unmarshal(stdout.Bytes(), &result); err != nil { + t.Fatal(err) + } + inspection := result.EnrollmentInspection + if inspection == nil || inspection.Schema != enrollmentInspectionSchema || + inspection.CeremonyID != definition.CeremonyID || inspection.Identity != record.Identity || + inspection.Role != test.role || inspection.RoleIndex != 1 || + inspection.IndependenceDisclosure != record.IndependenceDisclosure { + t.Fatalf("enrollment inspection = %#v", inspection) + } + + writeDecisionTestFile(t, recordPath, append(recordBytes, '\n'), 0o600) + stdout.Reset() + stderr.Reset() + if code := runCLI(context.Background(), args, &stdout, &stderr, workflowExecutor{}); code == 0 { + t.Fatal("altered enrollment unexpectedly accepted") + } + }) + } +} + +func writeInspectionTrustFixture( + t *testing.T, + root string, + definition mpcceremony.CeremonyDefinition, + coordinatorKey ed25519.PrivateKey, +) []string { + t.Helper() + definitionBytes, signatureBytes, err := mpcceremony.SignRecord( + definition, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + ceremonyPath := filepath.Join(root, "ceremony.json") + signaturePath := filepath.Join(root, "ceremony.sig") + publicKeyPath := filepath.Join(root, "coordinator-public-key.hex") + writeDecisionTestFile(t, ceremonyPath, definitionBytes, 0o600) + writeDecisionTestFile(t, signaturePath, signatureBytes, 0o600) + writeDecisionTestFile(t, publicKeyPath, []byte(definition.Coordinator.Ed25519PublicKeyHex+"\n"), 0o600) + return []string{ + "--ceremony", ceremonyPath, + "--ceremony-signature", signaturePath, + "--coordinator-public-key-file", publicKeyPath, + } +} + +func commandSignedExternalEnrollment( + t *testing.T, + definition mpcceremony.CeremonyDefinition, + role mpcceremony.EnrollmentRole, + id string, + fill byte, +) (mpcceremony.EnrollmentRecord, []byte, []byte, ed25519.PrivateKey) { + t.Helper() + privateKey := ed25519.NewKeyFromSeed(bytes.Repeat([]byte{fill}, ed25519.SeedSize)) + identity, err := mpcceremony.NewIdentity( + id, + "Test "+id, + id+"-key", + privateKey.Public().(ed25519.PublicKey), + ) + if err != nil { + t.Fatal(err) + } + definitionBytes, err := mpcceremony.MarshalCanonical(definition) + if err != nil { + t.Fatal(err) + } + record, err := mpcceremony.NewEnrollmentRecord( + definition, + definitionBytes, + identity, + role, + 1, + mpcceremony.ArtifactRef{ + Name: "disclosures/" + id + ".json", + Digest: mpcceremony.NewDigest([]byte("independent " + id)), + }, + "2026-07-23T12:00:01Z", + ) + if err != nil { + t.Fatal(err) + } + recordBytes, signatureBytes, err := mpcceremony.SignRecord(record, identity.KeyID, privateKey) + if err != nil { + t.Fatal(err) + } + return record, recordBytes, signatureBytes, privateKey +} + +func TestInspectDefinitionProjectsRelayView(t *testing.T) { + definition := mpcceremony.CeremonyDefinition{ + CeremonyID: "ceremony-id", + Mode: mpcceremony.ModeProduction, + Phase1Policy: mpcceremony.PhasePolicy{ + Participants: []string{"p1", "p2"}, + }, + Phase2Policy: mpcceremony.PhasePolicy{ + Participants: []string{"p2"}, + }, + Circuit: mpcceremony.CircuitBinding{ + R1CS: mpcceremony.ArtifactRef{Name: "ownership-destination.ccs"}, + }, + } + + got := inspectDefinition(definition) + if got.Schema != definitionInspectionSchema || got.CeremonyID != definition.CeremonyID || got.Mode != definition.Mode { + t.Fatalf("inspection identity = %#v", got) + } + if !reflect.DeepEqual(got.Phase1Participants, []string{"p1", "p2"}) || + !reflect.DeepEqual(got.Phase2Participants, []string{"p2"}) { + t.Fatalf("inspection schedules = %#v / %#v", got.Phase1Participants, got.Phase2Participants) + } + if got.R1CS != definition.Circuit.R1CS { + t.Fatalf("inspection r1cs = %#v", got.R1CS) + } + + definition.Phase1Policy.Participants[0] = "changed" + if got.Phase1Participants[0] != "p1" { + t.Fatal("inspection retained mutable definition schedule storage") + } +} + +func TestInspectChainProjectsStableArtifactOrder(t *testing.T) { + ref := func(name string) mpcceremony.ArtifactRef { return mpcceremony.ArtifactRef{Name: name} } + chain := mpcceremony.Chain{ + CeremonyID: "ceremony-id", + Phase: mpcceremony.Phase1, + Genesis: ref("genesis"), + Records: []mpcceremony.ChainRecord{{ + Index: 1, + RecordID: "record-id", + ParticipantID: "p1", + OutputPayload: ref("output"), + Attestation: ref("attestation"), + AttestationSignature: ref("attestation.sig"), + Erasure: ref("erasure"), + ErasureSignature: ref("erasure.sig"), + Verification: ref("verification"), + }}, + } + + got := inspectChain(chain) + if got.Schema != chainInspectionSchema || got.AcceptedCount != 1 || len(got.Records) != 1 { + t.Fatalf("inspection = %#v", got) + } + wantNames := []string{"genesis", "output", "attestation", "attestation.sig", "erasure", "erasure.sig", "verification"} + for index, want := range wantNames { + if got.Artifacts[index].Name != want { + t.Fatalf("artifact %d = %q, want %q", index, got.Artifacts[index].Name, want) + } + } + if !reflect.DeepEqual(got.Records[0].Artifacts, got.Artifacts[1:]) { + t.Fatalf("record artifacts = %#v, want %#v", got.Records[0].Artifacts, got.Artifacts[1:]) + } +} diff --git a/cmd/mpc-ceremony/integration_test.go b/cmd/mpc-ceremony/integration_test.go index 75f83ce..d95ab06 100644 --- a/cmd/mpc-ceremony/integration_test.go +++ b/cmd/mpc-ceremony/integration_test.go @@ -17,6 +17,10 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { topics := [][]string{ nil, {"init"}, + {"identity"}, + {"identity", "generate"}, + {"rehearsal"}, + {"rehearsal", "init"}, {"phase1"}, {"phase1", "contribute"}, {"phase1", "attest-erasure"}, @@ -42,7 +46,14 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { {"decision", "prepare"}, {"decision", "sign"}, {"decision", "verify"}, + {"inspect"}, + {"inspect", "definition"}, + {"inspect", "chain"}, + {"inspect", "participant"}, + {"inspect", "enrollment"}, {"ops"}, + {"ops", "prepare-public-witness-receipt"}, + {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, {"ops", "import-signature"}, {"ops", "verify"}, @@ -98,6 +109,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--beacon", "--beacon-signature", "--beacon-round", + "--beacon-round-lead", "--candidate-bundle", "--candidate-dir", "--ceremony", @@ -108,8 +120,11 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--closure", "--created-at", "--destroyed-at", + "--display-name", "--decision", "--draft", + "--enrollment", + "--enrollment-signature", "--evidence-root", "--coordinator-key-id", "--coordinator-public-key-file", @@ -117,17 +132,24 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--environment", "--finalized-at", "--format", + "--full", + "--identity-id", "--key-version", "--keys-dir", "--manifest-public-key-file", + "--mirror-enrollment", + "--mirror-enrollment-signature", "--mode", + "--observed-at", "--out", "--out-dir", "--published-at", "--participant-id", "--participant-signing-key", "--prepared-at", + "--publication-location", "--public-evidence", + "--public-identity-out", "--participants", "--phase1-beacon", "--phase1-beacon-signature", @@ -145,6 +167,7 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--phase2-close", "--phase2-close-signature", "--policy", + "--private-key-out", "--quiet", "--raw-response", "--release-dir", @@ -167,6 +190,8 @@ func TestParticipantCLIHelpHasExplicitSafeFlagAllowlist(t *testing.T) { "--signature-key-id", "--transcript-dir", "--transcript-root", + "--witness-enrollment", + "--witness-enrollment-signature", "--accepted-at", "--contributed-at", } @@ -196,6 +221,12 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { {Command: CommandDecisionPrepare, Options: DecisionPrepareOptions{}}, {Command: CommandDecisionSign, Options: DecisionSignOptions{}}, {Command: CommandDecisionVerify, Options: DecisionVerifyOptions{}}, + {Command: CommandOpsPreparePublicWitnessReceipt, Options: OpsPreparePublicWitnessReceiptOptions{}}, + {Command: CommandOpsPrepareMirrorReceipt, Options: OpsPrepareMirrorReceiptOptions{}}, + {Command: CommandInspectDefinition, Options: InspectDefinitionOptions{}}, + {Command: CommandInspectChain, Options: InspectChainOptions{}}, + {Command: CommandInspectParticipant, Options: InspectParticipantOptions{}}, + {Command: CommandInspectEnrollment, Options: InspectEnrollmentOptions{}}, } for _, invocation := range tests { t.Run(string(invocation.Command), func(t *testing.T) { @@ -210,6 +241,7 @@ func TestFinalizationAuditAndReleaseCommandsAreWired(t *testing.T) { func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { commands := [][]string{ {"init"}, + {"identity", "generate"}, {"phase1", "contribute"}, {"phase1", "attest-erasure"}, {"phase1", "verify"}, @@ -228,6 +260,12 @@ func TestEveryCommandRejectsWalletAndWitnessSecretInputs(t *testing.T) { {"release", "verify"}, {"decision", "sign"}, {"decision", "verify"}, + {"inspect", "definition"}, + {"inspect", "chain"}, + {"inspect", "participant"}, + {"inspect", "enrollment"}, + {"ops", "prepare-public-witness-receipt"}, + {"ops", "prepare-mirror-receipt"}, {"ops", "export-signing"}, {"ops", "import-signature"}, {"ops", "verify"}, diff --git a/cmd/mpc-ceremony/main.go b/cmd/mpc-ceremony/main.go index 0ea52ee..50d84b6 100644 --- a/cmd/mpc-ceremony/main.go +++ b/cmd/mpc-ceremony/main.go @@ -30,7 +30,7 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut var help *helpRequest if errors.As(err, &help) { if err := writeUsage(stdout, help.topic); err != nil { - writeDiagnostic(stderr, "error: write help: %v\n", err) + writeDiagnostic(stderr, args, "error: write help: %v\n", err) return 6 } return 0 @@ -39,17 +39,15 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut if errors.As(err, &usage) { message := redactCLIError(usage.message, args) if requestsJSON(args) { - return writeParseError(message, stdout, stderr) - } - if _, err := fmt.Fprintf(stderr, "error: %s\n\n", message); err != nil { - return 6 + return writeParseError(message, args, stdout, stderr) } + writeDiagnostic(stderr, args, "error: %s\n\n", message) if err := writeUsage(stderr, usage.topic); err != nil { return 6 } return 2 } - writeDiagnostic(stderr, "error: %s\n", redactCLIError(err.Error(), args)) + writeDiagnostic(stderr, args, "error: %s\n", err.Error()) return 6 } @@ -65,19 +63,31 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut result.Command = invocation.Command if invocation.Global.Format == "json" { if err := json.NewEncoder(stdout).Encode(result); err != nil { - writeDiagnostic(stderr, "error: encode command result: %v\n", err) + writeDiagnostic(stderr, args, "error: encode command result: %v\n", err) return 6 } return 0 } if result.Summary != "" { if _, err := fmt.Fprintln(stdout, result.Summary); err != nil { - writeDiagnostic(stderr, "error: write command result: %v\n", err) + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) return 6 } } else { if _, err := fmt.Fprintf(stdout, "%s completed\n", invocation.Command); err != nil { - writeDiagnostic(stderr, "error: write command result: %v\n", err) + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) + return 6 + } + } + if result.Identity != nil { + if _, err := fmt.Fprintf( + stdout, + "identity_id: %s\nkey_id: %s\npublic_key_fingerprint: %s\n", + result.Identity.ID, + result.Identity.KeyID, + result.Identity.PublicKeyFingerprint, + ); err != nil { + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) return 6 } } @@ -89,7 +99,7 @@ func runCLI(ctx context.Context, args []string, stdout, stderr io.Writer, execut for _, name := range names { path := result.Outputs[name] if _, err := fmt.Fprintf(stdout, "%s: %s\n", name, path); err != nil { - writeDiagnostic(stderr, "error: write command result: %v\n", err) + writeDiagnostic(stderr, args, "error: write command result: %v\n", err) return 6 } } @@ -120,11 +130,11 @@ func writeExecutionError(invocation Invocation, err error, args []string, stdout payload.Error.Code = code payload.Error.Message = message if encodeErr := json.NewEncoder(stdout).Encode(payload); encodeErr != nil { - writeDiagnostic(stderr, "error: encode command error: %v\n", encodeErr) + writeDiagnostic(stderr, args, "error: encode command error: %v\n", encodeErr) } return exitCode } - writeDiagnostic(stderr, "error: %s\n", message) + writeDiagnostic(stderr, args, "error: %s\n", message) return exitCode } @@ -161,11 +171,65 @@ func redactCLIError(message string, args []string) string { return len(ordered[i]) > len(ordered[j]) }) for _, candidate := range ordered { - message = strings.ReplaceAll(message, candidate, redactedCLIValue) + message = redactCandidate(message, candidate) } return message } +// shortCandidateLength is the length below which redaction switches from +// substring replacement to whole-token replacement. Long values are replaced +// wherever they appear: incidental collisions are vanishingly rare and a +// secret embedded in a longer string must still be caught. Short values are +// replaced only as complete tokens: a one-to-three character argument such as +// a participant count would otherwise blank matching digits and letters inside +// unrelated words, degrading the diagnostic exactly when it is needed. A short +// value that IS echoed verbatim — validateID permits one-character key ids — +// still appears as its own token and is still redacted, which is the leak that +// forced the revert of the plain length-floor approach. +const shortCandidateLength = 4 + +func redactCandidate(message, candidate string) string { + if len(candidate) >= shortCandidateLength { + return strings.ReplaceAll(message, candidate, redactedCLIValue) + } + var builder strings.Builder + remaining := message + for { + index := strings.Index(remaining, candidate) + if index < 0 { + builder.WriteString(remaining) + return builder.String() + } + before := remaining[:index] + after := remaining[index+len(candidate):] + if isTokenBoundary(before, true) && isTokenBoundary(after, false) { + builder.WriteString(before) + builder.WriteString(redactedCLIValue) + remaining = after + continue + } + builder.WriteString(remaining[:index+len(candidate)]) + remaining = after + } +} + +// isTokenBoundary reports whether the text adjacent to a candidate ends (or +// starts) a token: empty, or a byte that cannot continue an identifier or +// number. Letters and digits continue a token; everything else separates. +func isTokenBoundary(adjacent string, atEnd bool) bool { + if adjacent == "" { + return true + } + var b byte + if atEnd { + b = adjacent[len(adjacent)-1] + } else { + b = adjacent[0] + } + isAlphanumeric := b >= '0' && b <= '9' || b >= 'a' && b <= 'z' || b >= 'A' && b <= 'Z' + return !isAlphanumeric +} + func identifyCLICommandArguments(args []string) map[int]struct{} { safe := make(map[int]struct{}) index := 0 @@ -188,7 +252,8 @@ func identifyCLICommandArguments(args []string) map[int]struct{} { command: topLevel := map[string]struct{}{ "audit": {}, "decision": {}, "finalize": {}, "help": {}, "init": {}, - "ops": {}, "phase1": {}, "phase2": {}, "release": {}, + "inspect": {}, "ops": {}, "phase1": {}, "phase2": {}, "rehearsal": {}, + "release": {}, } if _, ok := topLevel[args[index]]; !ok { return safe @@ -205,8 +270,15 @@ command: "contribute": {}, "help": {}, "init": {}, "verify": {}, }, "decision": {"help": {}, "prepare": {}, "sign": {}, "verify": {}}, - "ops": {"export-signing": {}, "help": {}, "import-signature": {}, "verify": {}}, - "release": {"help": {}, "sign": {}, "verify": {}}, + "inspect": { + "chain": {}, "definition": {}, "enrollment": {}, "help": {}, "participant": {}, + }, + "ops": { + "export-signing": {}, "help": {}, "import-signature": {}, + "prepare-mirror-receipt": {}, "prepare-public-witness-receipt": {}, "verify": {}, + }, + "release": {"help": {}, "sign": {}, "verify": {}}, + "rehearsal": {"help": {}, "init": {}}, } allowed, hasSubcommands := subcommands[args[index]] if hasSubcommands && index+1 < len(args) { @@ -224,8 +296,12 @@ func addCLIErrorCandidate(candidates map[string]struct{}, value string) { candidates[value] = struct{}{} } -func writeDiagnostic(w io.Writer, format string, args ...any) { - _, _ = fmt.Fprintf(w, format, args...) +// writeDiagnostic is the only stderr outlet. It redacts the formatted message +// against argv by construction, so a new diagnostic call site cannot leak a +// command-line value by forgetting to call redactCLIError first. Call sites +// that already redacted are unaffected: redaction is idempotent. +func writeDiagnostic(w io.Writer, cliArgs []string, format string, args ...any) { + _, _ = fmt.Fprint(w, redactCLIError(fmt.Sprintf(format, args...), cliArgs)) } func requestsJSON(args []string) bool { @@ -242,7 +318,7 @@ func requestsJSON(args []string) bool { return false } -func writeParseError(message string, stdout, stderr io.Writer) int { +func writeParseError(message string, args []string, stdout, stderr io.Writer) int { payload := struct { Schema string `json:"schema"` OK bool `json:"ok"` @@ -257,7 +333,7 @@ func writeParseError(message string, stdout, stderr io.Writer) int { payload.Error.Code = "usage_error" payload.Error.Message = message if err := json.NewEncoder(stdout).Encode(payload); err != nil { - writeDiagnostic(stderr, "error: encode usage error: %v\n", err) + writeDiagnostic(stderr, args, "error: encode usage error: %v\n", err) return 6 } return 2 diff --git a/cmd/mpc-ceremony/ops.go b/cmd/mpc-ceremony/ops.go index 7d3ecae..c8ec8ad 100644 --- a/cmd/mpc-ceremony/ops.go +++ b/cmd/mpc-ceremony/ops.go @@ -18,6 +18,161 @@ import ( const maxOperationalRecordBytes = 16 << 20 +func executeOpsPreparePublicWitnessReceipt(options OpsPreparePublicWitnessReceiptOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }) + if err != nil { + return CommandResult{}, err + } + closure, closureName, err := mpcceremony.LoadSignedCloseExact( + trusted, + options.TranscriptRoot, + options.ClosurePath, + options.ClosureSignaturePath, + ) + if err != nil { + return CommandResult{}, err + } + enrollmentBytes, err := readRegularOperationalFile(options.WitnessEnrollmentPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + enrollmentSignatureBytes, err := readRegularOperationalFile(options.WitnessEnrollmentSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + enrollment, err := mpcceremony.VerifyEnrollmentProofOfPossession( + trusted.Definition, + definitionBytes, + enrollmentBytes, + enrollmentSignatureBytes, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("witness enrollment proof of possession: %w", err) + } + _, canonical, err := mpcceremony.PreparePublicWitnessReceipt( + trusted.Definition, + closure.Record, + closure.RecordBytes, + enrollment, + closureName, + options.PublicationLocation, + options.ObservedAt, + ) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordPublicWitness, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Phase: string(closure.Record.Phase), + Summary: "validated human-claimed publication observation and exported canonical public-witness receipt for offline signing", + Outputs: map[string]string{ + "canonical": canonicalPath, + "signing_request": requestPath, + }, + }, nil +} + +func executeOpsPrepareMirrorReceipt(options OpsPrepareMirrorReceiptOptions) (CommandResult, error) { + trusted, err := mpcceremony.LoadSignedDefinition(mpcceremony.TrustPaths{ + DefinitionPath: options.CeremonyPath, + DefinitionSignaturePath: options.CeremonySignaturePath, + CoordinatorPublicKeyPath: options.CoordinatorPublicKeyFile, + }) + if err != nil { + return CommandResult{}, err + } + chain, chainPrefix, err := mpcceremony.LoadSignedChainExact(trusted, mpcceremony.PhaseTranscriptPaths{ + RootDir: options.TranscriptRoot, + ChainPath: options.ChainPath, + ChainSignaturePath: options.ChainSignaturePath, + }) + if err != nil { + return CommandResult{}, err + } + draftBytes, err := readRegularOperationalFile(options.DraftPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + draft, err := mpcceremony.ParseMirrorReceiptDraft(draftBytes) + if err != nil { + return CommandResult{}, fmt.Errorf("mirror receipt draft: %w", err) + } + enrollmentBytes, err := readRegularOperationalFile(options.MirrorEnrollmentPath, maxOperationalRecordBytes) + if err != nil { + return CommandResult{}, err + } + enrollmentSignatureBytes, err := readRegularOperationalFile(options.MirrorEnrollmentSignaturePath, 4096) + if err != nil { + return CommandResult{}, err + } + definitionBytes, err := canonicalDefinition(trusted) + if err != nil { + return CommandResult{}, err + } + enrollment, err := mpcceremony.VerifyEnrollmentProofOfPossession( + trusted.Definition, + definitionBytes, + enrollmentBytes, + enrollmentSignatureBytes, + ) + if err != nil { + return CommandResult{}, fmt.Errorf("mirror enrollment proof of possession: %w", err) + } + _, canonical, err := mpcceremony.PrepareImmutableMirrorReceipt( + trusted.Definition, + chain, + chainPrefix, + draft, + enrollment, + ) + if err != nil { + return CommandResult{}, err + } + request, err := mpcceremony.NewOperationalSigningRequest(mpcceremony.RecordMirrorReceipt, canonical) + if err != nil { + return CommandResult{}, err + } + requestBytes, err := mpcceremony.MarshalCanonical(request) + if err != nil { + return CommandResult{}, err + } + canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Phase: string(chain.Phase), + Sequence: int(draft.Index), + Summary: "authenticated relay draft and exported exact canonical mirror receipt bytes for offline signing", + Outputs: map[string]string{ + "draft": options.DraftPath, + "canonical": canonicalPath, + "signing_request": requestPath, + }, + }, nil +} + func executeOpsExportSigning(options OpsExportSigningOptions) (result CommandResult, err error) { recordType := mpcceremony.OperationalRecordType(options.RecordType) canonical, record, trusted, err := loadBoundOperationalRecord( @@ -46,38 +201,46 @@ func executeOpsExportSigning(options OpsExportSigningOptions) (result CommandRes return CommandResult{}, err } - if err := os.Mkdir(options.OutDir, 0o700); err != nil { - return CommandResult{}, fmt.Errorf("create fresh signing export directory: %w", err) + canonicalPath, requestPath, err := writeOperationalSigningExport(options.OutDir, canonical, requestBytes) + if err != nil { + return CommandResult{}, err + } + return CommandResult{ + CeremonyID: trusted.Definition.CeremonyID, + Summary: "exported exact canonical operational record bytes and digest for offline signing", + Outputs: map[string]string{ + "canonical": canonicalPath, + "signing_request": requestPath, + }, + }, nil +} + +func writeOperationalSigningExport(outDir string, canonical, request []byte) (canonicalPath, requestPath string, err error) { + if err := os.Mkdir(outDir, 0o700); err != nil { + return "", "", fmt.Errorf("create fresh signing export directory: %w", err) } complete := false defer func() { if complete { return } - _ = os.Remove(filepath.Join(options.OutDir, "canonical.json")) - _ = os.Remove(filepath.Join(options.OutDir, "signing-request.json")) - _ = os.Remove(options.OutDir) + _ = os.Remove(filepath.Join(outDir, "canonical.json")) + _ = os.Remove(filepath.Join(outDir, "signing-request.json")) + _ = os.Remove(outDir) }() - canonicalPath := filepath.Join(options.OutDir, "canonical.json") - requestPath := filepath.Join(options.OutDir, "signing-request.json") + canonicalPath = filepath.Join(outDir, "canonical.json") + requestPath = filepath.Join(outDir, "signing-request.json") if err := writeFreshOperationalFile(canonicalPath, canonical, 0o600); err != nil { - return CommandResult{}, err + return "", "", err } - if err := writeFreshOperationalFile(requestPath, requestBytes, 0o600); err != nil { - return CommandResult{}, err + if err := writeFreshOperationalFile(requestPath, request, 0o600); err != nil { + return "", "", err } - if err := syncDirectory(options.OutDir); err != nil { - return CommandResult{}, err + if err := syncDirectory(outDir); err != nil { + return "", "", err } complete = true - return CommandResult{ - CeremonyID: trusted.Definition.CeremonyID, - Summary: "exported exact canonical operational record bytes and digest for offline signing", - Outputs: map[string]string{ - "canonical": canonicalPath, - "signing_request": requestPath, - }, - }, nil + return canonicalPath, requestPath, nil } func executeOpsImportSignature(options OpsImportSignatureOptions) (CommandResult, error) { diff --git a/cmd/mpc-ceremony/parse.go b/cmd/mpc-ceremony/parse.go index 9127a57..9dad6a9 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 } @@ -61,6 +67,17 @@ func parseInvocation(args []string) (Invocation, error) { options, err := parseInit(rest[1:]) invocation.Command, invocation.Options = CommandInit, options return invocation, wrapCommandError(err, "init") + case "identity": + return parseIdentity(invocation, rest[1:]) + case "rehearsal": + return parseRehearsal(invocation, rest[1:]) + case "inspect": + if len(rest) > 1 && !strings.HasPrefix(rest[1], "-") { + return parseInspectSubcommand(invocation, rest[1:]) + } + options, err := parseInspect(rest[1:]) + invocation.Command, invocation.Options = CommandInspect, options + return invocation, wrapCommandError(err, "inspect") case "phase1": return parsePhase1(invocation, rest[1:]) case "phase2": @@ -84,6 +101,201 @@ func parseInvocation(args []string) (Invocation, error) { } } +func parseIdentity(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing identity command", topic: []string{"identity"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"identity"}, args[1:]...)} + } + switch args[0] { + case "generate": + options, err := parseIdentityGenerate(args[1:]) + invocation.Command, invocation.Options = CommandIdentityGenerate, options + return invocation, wrapCommandError(err, "identity", "generate") + default: + return Invocation{}, &usageError{ + message: fmt.Sprintf("unknown identity command %q", args[0]), + topic: []string{"identity"}, + } + } +} + +func parseIdentityGenerate(args []string) (IdentityGenerateOptions, error) { + var options IdentityGenerateOptions + fs := commandFlagSet("identity generate") + fs.StringVar(&options.IdentityID, "identity-id", "", "stable ceremony role identity") + fs.StringVar(&options.DisplayName, "display-name", "", "human-readable identity name") + fs.StringVar(&options.PrivateKeyOut, "private-key-out", "", "fresh secret Ed25519 seed file") + fs.StringVar(&options.PublicIdentityOut, "public-identity-out", "", "fresh public identity JSON file") + if err := parseFlags(fs, args); err != nil { + return options, err + } + if err := requireValues( + value("--identity-id", options.IdentityID), + value("--display-name", options.DisplayName), + pathValue("--private-key-out", options.PrivateKeyOut), + pathValue("--public-identity-out", options.PublicIdentityOut), + ); err != nil { + return options, err + } + return options, nil +} + +func parseRehearsal(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing rehearsal command", topic: []string{"rehearsal"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"rehearsal"}, args[1:]...)} + } + switch args[0] { + case "init": + options, err := parseRehearsalInit(args[1:]) + invocation.Command, invocation.Options = CommandRehearsalInit, options + return invocation, wrapCommandError(err, "rehearsal", "init") + default: + return Invocation{}, &usageError{ + message: fmt.Sprintf("unknown rehearsal command %q", args[0]), + topic: []string{"rehearsal"}, + } + } +} + +func parseRehearsalInit(args []string) (RehearsalInitOptions, error) { + var options RehearsalInitOptions + fs := commandFlagSet("rehearsal init") + fs.StringVar(&options.CreatedAt, "created-at", "", "ceremony creation timestamp in RFC3339") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh rehearsal work directory") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + value("--created-at", options.CreatedAt), + pathValue("--out-dir", options.OutDir), + ) +} + +func parseInspectSubcommand(invocation Invocation, args []string) (Invocation, error) { + if len(args) == 0 { + return Invocation{}, &usageError{message: "missing inspect command", topic: []string{"inspect"}} + } + if args[0] == "help" { + return Invocation{}, &helpRequest{topic: append([]string{"inspect"}, args[1:]...)} + } + switch args[0] { + case "definition": + options, err := parseInspectDefinition(args[1:]) + invocation.Command, invocation.Options = CommandInspectDefinition, options + return invocation, wrapCommandError(err, "inspect", "definition") + case "chain": + options, err := parseInspectChain(args[1:]) + invocation.Command, invocation.Options = CommandInspectChain, options + return invocation, wrapCommandError(err, "inspect", "chain") + case "participant": + options, err := parseInspectParticipant(args[1:]) + invocation.Command, invocation.Options = CommandInspectParticipant, options + return invocation, wrapCommandError(err, "inspect", "participant") + case "enrollment": + options, err := parseInspectEnrollment(args[1:]) + invocation.Command, invocation.Options = CommandInspectEnrollment, options + return invocation, wrapCommandError(err, "inspect", "enrollment") + default: + return Invocation{}, &usageError{ + message: fmt.Sprintf("unknown inspect command %q", args[0]), + topic: []string{"inspect"}, + } + } +} + +func parseInspectParticipant(args []string) (InspectParticipantOptions, error) { + var options InspectParticipantOptions + fs := commandFlagSet("inspect participant") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + fs.StringVar(&options.ParticipantSigningKey, "participant-signing-key", "", "existing participant Ed25519 private key") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--participant-signing-key", options.ParticipantSigningKey), + ) +} + +func parseInspectEnrollment(args []string) (InspectEnrollmentOptions, error) { + var options InspectEnrollmentOptions + fs := commandFlagSet("inspect enrollment") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + fs.StringVar(&options.EnrollmentPath, "enrollment", "", "canonical operational enrollment record") + fs.StringVar(&options.EnrollmentSignaturePath, "enrollment-signature", "", "detached proof-of-possession signature") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--enrollment", options.EnrollmentPath), + pathValue("--enrollment-signature", options.EnrollmentSignaturePath), + ) +} + +func parseInspectDefinition(args []string) (InspectDefinitionOptions, error) { + var options InspectDefinitionOptions + fs := commandFlagSet("inspect definition") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + ) +} + +func parseInspectChain(args []string) (InspectChainOptions, error) { + var options InspectChainOptions + fs := commandFlagSet("inspect chain") + addCeremonyTrustFlags( + fs, + &options.CeremonyPath, + &options.CeremonySignaturePath, + &options.CoordinatorPublicKeyFile, + ) + fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local transcript root") + fs.StringVar(&options.ChainPath, "chain", "", "explicit accepted chain JSON path") + fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached accepted chain signature path") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-root", options.TranscriptRoot), + pathValue("--chain", options.ChainPath), + pathValue("--chain-signature", options.ChainSignaturePath), + ) +} + func parseDecision(invocation Invocation, args []string) (Invocation, error) { if len(args) == 0 { return Invocation{}, &usageError{message: "missing decision command", topic: []string{"decision"}} @@ -212,6 +424,14 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { return Invocation{}, &helpRequest{topic: append([]string{"ops"}, args[1:]...)} } switch args[0] { + case "prepare-public-witness-receipt": + options, err := parseOpsPreparePublicWitnessReceipt(args[1:]) + invocation.Command, invocation.Options = CommandOpsPreparePublicWitnessReceipt, options + return invocation, wrapCommandError(err, "ops", "prepare-public-witness-receipt") + case "prepare-mirror-receipt": + options, err := parseOpsPrepareMirrorReceipt(args[1:]) + invocation.Command, invocation.Options = CommandOpsPrepareMirrorReceipt, options + return invocation, wrapCommandError(err, "ops", "prepare-mirror-receipt") case "export-signing": options, err := parseOpsExportSigning(args[1:]) invocation.Command, invocation.Options = CommandOpsExportSigning, options @@ -232,6 +452,64 @@ func parseOps(invocation Invocation, args []string) (Invocation, error) { } } +func parseOpsPreparePublicWitnessReceipt(args []string) (OpsPreparePublicWitnessReceiptOptions, error) { + var options OpsPreparePublicWitnessReceiptOptions + fs := commandFlagSet("ops prepare-public-witness-receipt") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local root containing the signed closure") + fs.StringVar(&options.ClosurePath, "closure", "", "exact coordinator-signed closure record") + fs.StringVar(&options.ClosureSignaturePath, "closure-signature", "", "detached coordinator signature for the closure") + fs.StringVar(&options.WitnessEnrollmentPath, "witness-enrollment", "", "canonical public-witness proof-of-possession enrollment") + fs.StringVar(&options.WitnessEnrollmentSignaturePath, "witness-enrollment-signature", "", "detached witness enrollment signature") + fs.StringVar(&options.PublicationLocation, "publication-location", "", "human-observed publication URI; only its SHA-256 is recorded") + fs.StringVar(&options.ObservedAt, "observed-at", "", "human-claimed observation time in RFC3339 UTC") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for canonical receipt and signing request") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-root", options.TranscriptRoot), + pathValue("--closure", options.ClosurePath), + pathValue("--closure-signature", options.ClosureSignaturePath), + pathValue("--witness-enrollment", options.WitnessEnrollmentPath), + pathValue("--witness-enrollment-signature", options.WitnessEnrollmentSignaturePath), + value("--publication-location", options.PublicationLocation), + value("--observed-at", options.ObservedAt), + pathValue("--out-dir", options.OutDir), + ) +} + +func parseOpsPrepareMirrorReceipt(args []string) (OpsPrepareMirrorReceiptOptions, error) { + var options OpsPrepareMirrorReceiptOptions + fs := commandFlagSet("ops prepare-mirror-receipt") + fs.StringVar(&options.DraftPath, "draft", "", "human-reviewable mirror receipt draft from relay") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.TranscriptRoot, "transcript-root", "", "local root containing the accepted chain prefix") + fs.StringVar(&options.ChainPath, "chain", "", "exact coordinator-signed accepted chain prefix") + fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached coordinator signature for the chain prefix") + fs.StringVar(&options.MirrorEnrollmentPath, "mirror-enrollment", "", "canonical mirror-operator proof-of-possession enrollment") + fs.StringVar(&options.MirrorEnrollmentSignaturePath, "mirror-enrollment-signature", "", "detached mirror enrollment signature") + fs.StringVar(&options.OutDir, "out-dir", "", "fresh directory for canonical receipt and signing request") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--draft", options.DraftPath), + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-root", options.TranscriptRoot), + pathValue("--chain", options.ChainPath), + pathValue("--chain-signature", options.ChainSignaturePath), + pathValue("--mirror-enrollment", options.MirrorEnrollmentPath), + pathValue("--mirror-enrollment-signature", options.MirrorEnrollmentSignaturePath), + pathValue("--out-dir", options.OutDir), + ) +} + func parseOpsExportSigning(args []string) (OpsExportSigningOptions, error) { var options OpsExportSigningOptions fs := commandFlagSet("ops export-signing") @@ -412,7 +690,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") @@ -425,8 +703,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) @@ -560,6 +846,8 @@ func parseClose(name string, args []string, phase2 bool) (CloseOptions, error) { fs.StringVar(&options.ChainSignaturePath, "chain-signature", "", "detached final accepted chain signature path") fs.StringVar(&options.CoordinatorSigningKey, "coordinator-signing-key", "", "existing Ed25519 coordinator private key path") fs.Uint64Var(&options.BeaconRound, "beacon-round", 0, "precommitted future beacon round") + fs.UintVar(&options.BeaconRoundLeadSeconds, "beacon-round-lead", 0, + "derive the beacon round this many seconds past the clock sampled after replay") if err := parseFlags(fs, args); err != nil { return options, err } @@ -572,8 +860,12 @@ func parseClose(name string, args []string, phase2 bool) (CloseOptions, error) { pathValue("--chain-signature", options.ChainSignaturePath), pathValue("--coordinator-signing-key", options.CoordinatorSigningKey), } - if options.BeaconRound == 0 { - required = append(required, requiredValue{name: "--beacon-round"}) + // A close replays for hours at K=21 before it stamps closed_at, so naming + // the round up front asks the operator to predict their own replay time. + // --beacon-round-lead derives it from the clock sampled after the replay. + if (options.BeaconRound == 0) == (options.BeaconRoundLeadSeconds == 0) { + return options, errors.New( + "exactly one of --beacon-round and --beacon-round-lead is required") } if phase2 { required = append( @@ -867,6 +1159,9 @@ func validateAuditArtifacts(reports, signatures []string) error { if len(reports) < 2 { return errors.New("--audit-report must be supplied at least twice for independent audits") } + if len(reports) > mpcceremony.MaxAuditors { + return fmt.Errorf("--audit-report supplied %d times, exceeds maximum %d recordable in the final transcript", len(reports), mpcceremony.MaxAuditors) + } if len(reports) != len(signatures) { return errors.New("--audit-report and --audit-signature counts must match") } @@ -973,3 +1268,20 @@ func (s *stringList) Set(value string) error { *s = append(*s, value) return nil } + +func parseInspect(args []string) (InspectOptions, error) { + var options InspectOptions + fs := commandFlagSet("inspect") + addCeremonyTrustFlags(fs, &options.CeremonyPath, &options.CeremonySignaturePath, &options.CoordinatorPublicKeyFile) + fs.StringVar(&options.TranscriptDir, "transcript-dir", "", "ceremony transcript root directory") + fs.BoolVar(&options.Full, "full", false, "re-verify every chain record and artifact digest instead of metadata only") + if err := parseFlags(fs, args); err != nil { + return options, err + } + return options, requireValues( + pathValue("--ceremony", options.CeremonyPath), + pathValue("--ceremony-signature", options.CeremonySignaturePath), + pathValue("--coordinator-public-key-file", options.CoordinatorPublicKeyFile), + pathValue("--transcript-dir", options.TranscriptDir), + ) +} diff --git a/cmd/mpc-ceremony/public_witness_ops_test.go b/cmd/mpc-ceremony/public_witness_ops_test.go new file mode 100644 index 0000000..c7fd29d --- /dev/null +++ b/cmd/mpc-ceremony/public_witness_ops_test.go @@ -0,0 +1,267 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "proof-tool/internal/mpcceremony" +) + +func TestPreparePublicWitnessReceiptAuthenticatesClosureEnrollmentAndOutput(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + trustArgs := writeInspectionTrustFixture(t, root, definition, coordinatorKey) + trust := trustOptionsFromArgs(t, trustArgs) + + round := uint64(40_000_000) + roundTime, err := mpcceremony.QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + closeRecord, err := mpcceremony.NewCloseRecord(mpcceremony.CloseRecord{ + CeremonyID: definition.CeremonyID, + Phase: mpcceremony.Phase1, + PhaseID: "sha256:" + strings.Repeat("44", 32), + FinalIndex: 1, + FinalPayload: commandArtifact("phase1/final.bin", "final"), + ChainHeadID: "sha256:" + strings.Repeat("55", 32), + AcceptedParticipants: []string{definition.Roster[0].Identity.ID}, + BeaconProvider: definition.BeaconPolicy.Provider, + BeaconNetwork: definition.BeaconPolicy.Network, + BeaconRound: round, + BeaconNotBefore: roundTime.Format(time.RFC3339), + ClosedAt: roundTime.Add(-25 * time.Hour).Format(time.RFC3339), + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + }) + if err != nil { + t.Fatal(err) + } + closeBytes, closeSignature, err := mpcceremony.SignRecord( + closeRecord, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + closurePath := filepath.Join(root, "phase1", "closure", "record.json") + closureSignaturePath := filepath.Join(root, "phase1", "closure", "record.sig") + if err := os.MkdirAll(filepath.Dir(closurePath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, closurePath, closeBytes, 0o600) + writeDecisionTestFile(t, closureSignaturePath, closeSignature, 0o600) + + witness, witnessBytes, witnessSignature, _ := commandSignedExternalEnrollment( + t, + definition, + mpcceremony.EnrollmentPublicWitness, + "public-witness-01", + 0x91, + ) + witnessPath := filepath.Join(root, "operational", "enrollments", "public-witness-01.json") + witnessSignaturePath := filepath.Join(root, "operational", "enrollments", "public-witness-01.sig") + if err := os.MkdirAll(filepath.Dir(witnessPath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, witnessPath, witnessBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, witnessSignature, 0o600) + + location := "https://independent.example/phase1/closure.json" + options := OpsPreparePublicWitnessReceiptOptions{ + CeremonyPath: trust.CeremonyPath, + CeremonySignaturePath: trust.CeremonySignaturePath, + CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyFile, + TranscriptRoot: root, + ClosurePath: closurePath, + ClosureSignaturePath: closureSignaturePath, + WitnessEnrollmentPath: witnessPath, + WitnessEnrollmentSignaturePath: witnessSignaturePath, + PublicationLocation: location, + ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339), + OutDir: filepath.Join(root, "witness-signing"), + } + result, err := executeOpsPreparePublicWitnessReceipt(options) + if err != nil { + t.Fatal(err) + } + canonical, err := os.ReadFile(result.Outputs["canonical"]) + if err != nil { + t.Fatal(err) + } + if bytes.Contains(canonical, []byte(location)) { + t.Fatal("canonical receipt contains cleartext publication location") + } + var receipt mpcceremony.PublicWitnessReceipt + if err := mpcceremony.UnmarshalCanonical(canonical, &receipt); err != nil { + t.Fatal(err) + } + if receipt.Witness != witness.Identity || receipt.Closure.Name != "phase1/closure/record.json" || + receipt.ObservedAt != options.ObservedAt { + t.Fatalf("receipt = %#v", receipt) + } + requestBytes, err := os.ReadFile(result.Outputs["signing_request"]) + if err != nil { + t.Fatal(err) + } + var request mpcceremony.OperationalSigningRequest + if err := mpcceremony.UnmarshalCanonical(requestBytes, &request); err != nil { + t.Fatal(err) + } + if request.RecordType != mpcceremony.RecordPublicWitness { + t.Fatalf("signing request record type = %q", request.RecordType) + } + + canonicalBefore := append([]byte(nil), canonical...) + if _, err := executeOpsPreparePublicWitnessReceipt(options); err == nil { + t.Fatal("existing output directory unexpectedly replaced") + } + canonicalAfter, err := os.ReadFile(result.Outputs["canonical"]) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(canonicalBefore, canonicalAfter) { + t.Fatal("failed retry changed existing canonical receipt") + } + + mirror, mirrorBytes, mirrorSignature, _ := commandSignedExternalEnrollment( + t, + definition, + mpcceremony.EnrollmentMirrorOperator, + "mirror-operator-01", + 0xa1, + ) + _ = mirror + writeDecisionTestFile(t, witnessPath, mirrorBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, mirrorSignature, 0o600) + wrongRole := options + wrongRole.OutDir = filepath.Join(root, "wrong-role") + if _, err := executeOpsPreparePublicWitnessReceipt(wrongRole); err == nil { + t.Fatal("mirror enrollment unexpectedly prepared a witness receipt") + } + if _, err := os.Stat(wrongRole.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("wrong-role preparation left output directory: %v", err) + } + + overlap := witness + overlap.Identity = definition.Coordinator + overlapBytes, overlapSignature, err := mpcceremony.SignRecord( + overlap, + definition.Coordinator.KeyID, + coordinatorKey, + ) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, witnessPath, overlapBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, overlapSignature, 0o600) + overlapOptions := options + overlapOptions.OutDir = filepath.Join(root, "overlap") + if _, err := executeOpsPreparePublicWitnessReceipt(overlapOptions); err == nil { + t.Fatal("ceremony actor unexpectedly accepted as public witness") + } +} + +func TestPreparePublicWitnessReceiptRejectsAlteredAndWronglySignedClosure(t *testing.T) { + root := t.TempDir() + definition, _, coordinatorKey := decisionSignFixture(t) + trust := trustOptionsFromArgs(t, writeInspectionTrustFixture(t, root, definition, coordinatorKey)) + round := uint64(40_000_000) + roundTime, _ := mpcceremony.QuicknetRoundTime(round) + closeRecord, err := mpcceremony.NewCloseRecord(mpcceremony.CloseRecord{ + CeremonyID: definition.CeremonyID, + Phase: mpcceremony.Phase1, + PhaseID: "sha256:" + strings.Repeat("44", 32), + FinalIndex: 1, + FinalPayload: commandArtifact("phase1/final.bin", "final"), + ChainHeadID: "sha256:" + strings.Repeat("55", 32), + AcceptedParticipants: []string{definition.Roster[0].Identity.ID}, + BeaconProvider: definition.BeaconPolicy.Provider, + BeaconNetwork: definition.BeaconPolicy.Network, + BeaconRound: round, + BeaconNotBefore: roundTime.Format(time.RFC3339), + ClosedAt: roundTime.Add(-25 * time.Hour).Format(time.RFC3339), + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, + }) + if err != nil { + t.Fatal(err) + } + closeBytes, closeSignature, err := mpcceremony.SignRecord(closeRecord, definition.Coordinator.KeyID, coordinatorKey) + if err != nil { + t.Fatal(err) + } + closurePath := filepath.Join(root, "phase1", "closure", "record.json") + closureSignaturePath := filepath.Join(root, "phase1", "closure", "record.sig") + if err := os.MkdirAll(filepath.Dir(closurePath), 0o700); err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, closurePath, closeBytes, 0o600) + writeDecisionTestFile(t, closureSignaturePath, closeSignature, 0o600) + _, witnessBytes, witnessSignature, witnessKey := commandSignedExternalEnrollment( + t, definition, mpcceremony.EnrollmentPublicWitness, "public-witness-01", 0x91, + ) + witnessPath := filepath.Join(root, "witness.json") + witnessSignaturePath := filepath.Join(root, "witness.sig") + writeDecisionTestFile(t, witnessPath, witnessBytes, 0o600) + writeDecisionTestFile(t, witnessSignaturePath, witnessSignature, 0o600) + options := OpsPreparePublicWitnessReceiptOptions{ + CeremonyPath: trust.CeremonyPath, + CeremonySignaturePath: trust.CeremonySignaturePath, + CoordinatorPublicKeyFile: trust.CoordinatorPublicKeyFile, + TranscriptRoot: root, + ClosurePath: closurePath, + ClosureSignaturePath: closureSignaturePath, + WitnessEnrollmentPath: witnessPath, + WitnessEnrollmentSignaturePath: witnessSignaturePath, + PublicationLocation: "https://independent.example/closure", + ObservedAt: roundTime.Add(-24 * time.Hour).Format(time.RFC3339), + OutDir: filepath.Join(root, "altered-output"), + } + + writeDecisionTestFile(t, closurePath, append(closeBytes, '\n'), 0o600) + if _, err := executeOpsPreparePublicWitnessReceipt(options); err == nil { + t.Fatal("altered closure unexpectedly accepted") + } + writeDecisionTestFile(t, closurePath, closeBytes, 0o600) + _, wrongSignature, err := mpcceremony.SignRecord(closeRecord, "public-witness-01-key", witnessKey) + if err != nil { + t.Fatal(err) + } + writeDecisionTestFile(t, closureSignaturePath, wrongSignature, 0o600) + if _, err := executeOpsPreparePublicWitnessReceipt(options); err == nil { + t.Fatal("closure signed by witness key unexpectedly accepted") + } + if _, err := os.Stat(options.OutDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("rejected closures left output directory: %v", err) + } +} + +func TestWriteOperationalSigningExportCleansPartialPublication(t *testing.T) { + outDir := filepath.Join(t.TempDir(), "partial") + if _, _, err := writeOperationalSigningExport(outDir, []byte("canonical"), nil); err == nil { + t.Fatal("empty signing request unexpectedly exported") + } + if _, err := os.Stat(outDir); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("partial signing export was not removed: %v", err) + } +} + +func trustOptionsFromArgs(t *testing.T, args []string) InspectDefinitionOptions { + t.Helper() + invocation, err := parseInvocation(append([]string{"inspect", "definition"}, args...)) + if err != nil { + t.Fatal(err) + } + return invocation.Options.(InspectDefinitionOptions) +} + +func commandArtifact(name, contents string) mpcceremony.ArtifactRef { + return mpcceremony.ArtifactRef{Name: name, Digest: mpcceremony.NewDigest([]byte(contents))} +} diff --git a/cmd/mpc-ceremony/redaction_test.go b/cmd/mpc-ceremony/redaction_test.go new file mode 100644 index 0000000..626f24f --- /dev/null +++ b/cmd/mpc-ceremony/redaction_test.go @@ -0,0 +1,77 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "bytes" + "strings" + "testing" +) + +func TestRedactShortValuesOnlyAsWholeTokens(t *testing.T) { + t.Parallel() + + // A short argument value must not blank matching characters inside + // unrelated numbers and words. + args := []string{"phase1", "verify", "--candidate-dir", "3"} + message := "chain has 3 records at index 13 under /tmp/3/state" + redacted := redactCLIError(message, args) + if strings.Contains(redacted, "index 1"+redactedCLIValue) { + t.Fatalf("digit inside a larger number was blanked: %q", redacted) + } + if !strings.Contains(redacted, "index 13") { + t.Fatalf("unrelated number damaged: %q", redacted) + } + if !strings.Contains(redacted, "has "+redactedCLIValue+" records") { + t.Fatalf("standalone short value survived: %q", redacted) + } + if !strings.Contains(redacted, "/tmp/"+redactedCLIValue+"/state") { + t.Fatalf("short path segment survived: %q", redacted) + } +} + +func TestRedactShortKeyIDStillRedactedAsToken(t *testing.T) { + t.Parallel() + + // validateID permits identifiers as short as one character. A short key + // id echoed verbatim appears as its own token and must still be redacted; + // this is the leak that forced reverting the plain length-floor approach. + args := []string{"phase1", "verify", "--participant", "p3"} + redacted := redactCLIError(`participant "p3" is not in the signed roster`, args) + if strings.Contains(redacted, "p3") { + t.Fatalf("short identifier leaked: %q", redacted) + } + // The same short value inside a longer token is a different token and + // stays readable. + other := redactCLIError("participant p30 is not scheduled", args) + if !strings.Contains(other, "p30") { + t.Fatalf("longer identifier containing the short value was damaged: %q", other) + } +} + +func TestRedactLongValuesAnywhere(t *testing.T) { + t.Parallel() + + args := []string{"phase1", "verify", "--key", "SENSITIVE-VALUE"} + redacted := redactCLIError("open /keys/SENSITIVE-VALUE.hex failed", args) + if strings.Contains(redacted, "SENSITIVE-VALUE") { + t.Fatalf("long value leaked as substring: %q", redacted) + } +} + +func TestWriteDiagnosticRedactsByConstruction(t *testing.T) { + t.Parallel() + + // A diagnostic call site that never called redactCLIError must still not + // echo a caller-supplied value. + args := []string{"phase1", "verify", "--key", "SENSITIVE-SENTINEL"} + var out bytes.Buffer + writeDiagnostic(&out, args, "error: open %s: no such file\n", "SENSITIVE-SENTINEL") + if strings.Contains(out.String(), "SENSITIVE-SENTINEL") { + t.Fatalf("writeDiagnostic leaked an argument value: %q", out.String()) + } + if !strings.Contains(out.String(), redactedCLIValue) { + t.Fatalf("writeDiagnostic did not mark the redaction: %q", out.String()) + } +} diff --git a/cmd/mpc-ceremony/rehearsal.go b/cmd/mpc-ceremony/rehearsal.go new file mode 100644 index 0000000..3c2fa71 --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal.go @@ -0,0 +1,63 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "errors" + "os" + "path/filepath" + + "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" +) + +const ( + rehearsalParticipantCount = 3 + rehearsalBeaconLeadSeconds = 300 +) + +func executeRehearsalInit(options RehearsalInitOptions) (result CommandResult, err error) { + if err := mpcrehearsal.Generate( + options.OutDir, + rehearsalParticipantCount, + rehearsalBeaconLeadSeconds, + ); err != nil { + return CommandResult{}, err + } + keepRoot := false + defer func() { + if !keepRoot { + err = errors.Join(err, os.RemoveAll(options.OutDir)) + } + }() + + configRoot := filepath.Join(options.OutDir, "config") + keyRoot := filepath.Join(options.OutDir, "keys") + participantsPath := filepath.Join(configRoot, "participants.json") + participants, err := mpcceremony.LoadInitParticipants(participantsPath) + if err != nil { + return CommandResult{}, err + } + result, err = executeInit(InitOptions{ + CreatedAt: options.CreatedAt, + KeyVersion: rehearsalKeyVersion, + ParticipantsPath: participantsPath, + PolicyPath: filepath.Join(configRoot, "policy.json"), + CoordinatorKeyID: participants.Coordinator.KeyID, + CoordinatorSigningKey: filepath.Join(keyRoot, "coordinator.ed25519.private.hex"), + OutDir: filepath.Join(options.OutDir, "public"), + Mode: mpcceremony.ModeRehearsal, + }) + if err != nil { + return CommandResult{}, err + } + result.Command = CommandRehearsalInit + result.Summary = "initialized same-host three-participant rehearsal fixture (NOT PRODUCTION)" + result.Outputs["config_root"] = configRoot + result.Outputs["environment"] = filepath.Join(configRoot, "environment.json") + result.Outputs["key_root"] = keyRoot + result.Outputs["rehearsal_root"] = options.OutDir + keepRoot = true + return result, nil +} diff --git a/cmd/mpc-ceremony/rehearsal_test.go b/cmd/mpc-ceremony/rehearsal_test.go new file mode 100644 index 0000000..163343f --- /dev/null +++ b/cmd/mpc-ceremony/rehearsal_test.go @@ -0,0 +1,62 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "strings" + "testing" +) + +func TestParseRehearsalInitIsNarrowAndExplicit(t *testing.T) { + t.Parallel() + + invocation, err := parseInvocation([]string{ + "rehearsal", "init", + "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", + }) + if err != nil { + t.Fatal(err) + } + if invocation.Command != CommandRehearsalInit { + t.Fatalf("command = %q", invocation.Command) + } + options := invocation.Options.(RehearsalInitOptions) + if options.CreatedAt != "2026-08-20T06:00:00Z" || options.OutDir != "/secure/rehearsal" { + t.Fatalf("options = %+v", options) + } + + for name, args := range map[string][]string{ + "missing creation time": {"rehearsal", "init", "--out-dir", "/secure/rehearsal"}, + "missing output": {"rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z"}, + "production mode": { + "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", "--mode", "production", + }, + "production circuit": { + "rehearsal", "init", "--created-at", "2026-08-20T06:00:00Z", + "--out-dir", "/secure/rehearsal", "--key-version", supportedKeyVersion, + }, + } { + t.Run(name, func(t *testing.T) { + t.Parallel() + if _, err := parseInvocation(args); err == nil { + t.Fatal("unsafe rehearsal initializer invocation was accepted") + } + }) + } +} + +func TestRehearsalInitHelpLabelsOutputAsNonProduction(t *testing.T) { + t.Parallel() + + var output strings.Builder + if err := writeUsage(&output, []string{"rehearsal", "init"}); err != nil { + t.Fatal(err) + } + lower := strings.ToLower(output.String()) + if !strings.Contains(lower, "rehearsal-tiny-v1") || !strings.Contains(lower, "not production") { + t.Fatalf("help does not state the rehearsal boundary: %q", output.String()) + } +} diff --git a/cmd/mpc-ceremony/types.go b/cmd/mpc-ceremony/types.go index edfa89d..3282ce9 100644 --- a/cmd/mpc-ceremony/types.go +++ b/cmd/mpc-ceremony/types.go @@ -6,6 +6,8 @@ package main import ( "context" "errors" + + "proof-tool/internal/mpcceremony" ) const commandResultSchema = "proof-tool-mpc-command-result-v1" @@ -13,30 +15,39 @@ const commandResultSchema = "proof-tool-mpc-command-result-v1" type Command string const ( - CommandInit Command = "init" - CommandPhase1Contribute Command = "phase1 contribute" - CommandPhase1Erasure Command = "phase1 attest-erasure" - CommandPhase1Verify Command = "phase1 verify" - CommandPhase1Close Command = "phase1 close" - CommandPhase1Beacon Command = "phase1 beacon" - CommandPhase1Seal Command = "phase1 seal" - CommandPhase2Init Command = "phase2 init" - CommandPhase2Contribute Command = "phase2 contribute" - CommandPhase2Erasure Command = "phase2 attest-erasure" - CommandPhase2Verify Command = "phase2 verify" - CommandPhase2Close Command = "phase2 close" - CommandPhase2Beacon Command = "phase2 beacon" - CommandFinalizePrepare Command = "finalize prepare" - CommandFinalizeComplete Command = "finalize complete" - CommandAudit Command = "audit" - CommandReleaseSign Command = "release sign" - CommandReleaseVerify Command = "release verify" - CommandOpsExportSigning Command = "ops export-signing" - CommandOpsImportSig Command = "ops import-signature" - CommandOpsVerify Command = "ops verify" - CommandDecisionPrepare Command = "decision prepare" - CommandDecisionSign Command = "decision sign" - CommandDecisionVerify Command = "decision verify" + CommandInit Command = "init" + CommandIdentityGenerate Command = "identity generate" + CommandRehearsalInit Command = "rehearsal init" + CommandInspect Command = "inspect" + CommandPhase1Contribute Command = "phase1 contribute" + CommandPhase1Erasure Command = "phase1 attest-erasure" + CommandPhase1Verify Command = "phase1 verify" + CommandPhase1Close Command = "phase1 close" + CommandPhase1Beacon Command = "phase1 beacon" + CommandPhase1Seal Command = "phase1 seal" + CommandPhase2Init Command = "phase2 init" + CommandPhase2Contribute Command = "phase2 contribute" + CommandPhase2Erasure Command = "phase2 attest-erasure" + CommandPhase2Verify Command = "phase2 verify" + CommandPhase2Close Command = "phase2 close" + CommandPhase2Beacon Command = "phase2 beacon" + CommandFinalizePrepare Command = "finalize prepare" + CommandFinalizeComplete Command = "finalize complete" + CommandAudit Command = "audit" + CommandReleaseSign Command = "release sign" + CommandReleaseVerify Command = "release verify" + CommandOpsPrepareMirrorReceipt Command = "ops prepare-mirror-receipt" + CommandOpsPreparePublicWitnessReceipt Command = "ops prepare-public-witness-receipt" + CommandOpsExportSigning Command = "ops export-signing" + CommandOpsImportSig Command = "ops import-signature" + CommandOpsVerify Command = "ops verify" + CommandDecisionPrepare Command = "decision prepare" + CommandDecisionSign Command = "decision sign" + CommandDecisionVerify Command = "decision verify" + CommandInspectDefinition Command = "inspect definition" + CommandInspectChain Command = "inspect chain" + CommandInspectParticipant Command = "inspect participant" + CommandInspectEnrollment Command = "inspect enrollment" ) type GlobalOptions struct { @@ -50,6 +61,13 @@ type Invocation struct { Options any } +type IdentityGenerateOptions struct { + IdentityID string + DisplayName string + PrivateKeyOut string + PublicIdentityOut string +} + type InitOptions struct { SessionNonceHex string CreatedAt string @@ -62,6 +80,11 @@ type InitOptions struct { Mode string } +type RehearsalInitOptions struct { + CreatedAt string + OutDir string +} + type ContributeOptions struct { CeremonyPath string CeremonySignaturePath string @@ -113,6 +136,7 @@ type CloseOptions struct { ChainSignaturePath string CoordinatorSigningKey string BeaconRound uint64 + BeaconRoundLeadSeconds uint } type Phase1SealOptions struct { @@ -219,6 +243,33 @@ type OpsExportSigningOptions struct { OutDir string } +type OpsPrepareMirrorReceiptOptions struct { + DraftPath string + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + TranscriptRoot string + ChainPath string + ChainSignaturePath string + MirrorEnrollmentPath string + MirrorEnrollmentSignaturePath string + OutDir string +} + +type OpsPreparePublicWitnessReceiptOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + TranscriptRoot string + ClosurePath string + ClosureSignaturePath string + WitnessEnrollmentPath string + WitnessEnrollmentSignaturePath string + PublicationLocation string + ObservedAt string + OutDir string +} + type OpsImportSignatureOptions struct { RecordType string CanonicalPath string @@ -242,6 +293,75 @@ type OpsVerifyOptions struct { EvidenceRoot string } +type InspectDefinitionOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string +} + +type InspectChainOptions struct { + InspectDefinitionOptions + TranscriptRoot string + ChainPath string + ChainSignaturePath string +} + +type InspectParticipantOptions struct { + InspectDefinitionOptions + ParticipantSigningKey string +} + +type InspectEnrollmentOptions struct { + InspectDefinitionOptions + EnrollmentPath string + EnrollmentSignaturePath string +} + +type DefinitionInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Mode string `json:"mode"` + Phase1Participants []string `json:"phase1_participants"` + Phase2Participants []string `json:"phase2_participants"` + R1CS mpcceremony.ArtifactRef `json:"r1cs"` +} + +type ChainRecordInspection struct { + Index uint8 `json:"index"` + RecordID string `json:"record_id"` + ParticipantID string `json:"participant_id"` + Artifacts []mpcceremony.ArtifactRef `json:"artifacts"` +} + +type ChainInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Phase mpcceremony.Phase `json:"phase"` + AcceptedCount int `json:"accepted_count"` + Artifacts []mpcceremony.ArtifactRef `json:"artifacts"` + Records []ChainRecordInspection `json:"records"` +} + +type ParticipantInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + ParticipantID string `json:"participant_id"` + KeyID string `json:"key_id"` + PublicKeyFingerprint string `json:"public_key_fingerprint"` + Phase1Position *uint8 `json:"phase1_position"` + Phase2Position *uint8 `json:"phase2_position"` +} + +type EnrollmentInspection struct { + Schema string `json:"schema"` + CeremonyID string `json:"ceremony_id"` + Identity mpcceremony.Identity `json:"identity"` + Role mpcceremony.EnrollmentRole `json:"role"` + RoleIndex uint16 `json:"role_index"` + EnrolledAt string `json:"enrolled_at"` + IndependenceDisclosure mpcceremony.ArtifactRef `json:"independence_disclosure"` +} + type DecisionSignOptions struct { CeremonyPath string CeremonySignaturePath string @@ -290,23 +410,28 @@ type ReplayOptions struct { } type CommandResult struct { - Schema string `json:"schema"` - OK bool `json:"ok"` - Command Command `json:"command"` - CeremonyID string `json:"ceremony_id,omitempty"` - Phase string `json:"phase,omitempty"` - Sequence int `json:"sequence,omitempty"` - ClosedAt string `json:"closed_at,omitempty"` - Decision string `json:"decision,omitempty"` - DecisionID string `json:"decision_id,omitempty"` - ReleaseID string `json:"release_id,omitempty"` - CandidateID string `json:"candidate_id,omitempty"` - SourceCommit string `json:"source_commit,omitempty"` - SourceSignedTag string `json:"source_signed_tag,omitempty"` - SourceTagSignerFingerprint string `json:"source_tag_signer_fingerprint,omitempty"` - SourceTagObjectSHA256 string `json:"source_tag_object_sha256,omitempty"` - Outputs map[string]string `json:"outputs,omitempty"` - Summary string `json:"summary,omitempty"` + Schema string `json:"schema"` + OK bool `json:"ok"` + Command Command `json:"command"` + CeremonyID string `json:"ceremony_id,omitempty"` + Phase string `json:"phase,omitempty"` + Sequence int `json:"sequence,omitempty"` + ClosedAt string `json:"closed_at,omitempty"` + Decision string `json:"decision,omitempty"` + DecisionID string `json:"decision_id,omitempty"` + ReleaseID string `json:"release_id,omitempty"` + CandidateID string `json:"candidate_id,omitempty"` + SourceCommit string `json:"source_commit,omitempty"` + SourceSignedTag string `json:"source_signed_tag,omitempty"` + SourceTagSignerFingerprint string `json:"source_tag_signer_fingerprint,omitempty"` + SourceTagObjectSHA256 string `json:"source_tag_object_sha256,omitempty"` + Outputs map[string]string `json:"outputs,omitempty"` + Summary string `json:"summary,omitempty"` + Identity *mpcceremony.Identity `json:"identity,omitempty"` + DefinitionInspection *DefinitionInspection `json:"definition_inspection,omitempty"` + ChainInspection *ChainInspection `json:"chain_inspection,omitempty"` + ParticipantInspection *ParticipantInspection `json:"participant_inspection,omitempty"` + EnrollmentInspection *EnrollmentInspection `json:"enrollment_inspection,omitempty"` } type Executor interface { @@ -326,3 +451,12 @@ type unwiredExecutor struct{} func (unwiredExecutor) Execute(context.Context, Invocation) (CommandResult, error) { return CommandResult{}, errExecutorNotWired } + +// InspectOptions configures the read-only ceremony inspection command. +type InspectOptions struct { + CeremonyPath string + CeremonySignaturePath string + CoordinatorPublicKeyFile string + TranscriptDir string + Full bool +} diff --git a/cmd/mpc-ceremony/usage.go b/cmd/mpc-ceremony/usage.go index 56d8822..a94e293 100644 --- a/cmd/mpc-ceremony/usage.go +++ b/cmd/mpc-ceremony/usage.go @@ -23,11 +23,17 @@ const rootHelp = `Usage: mpc-ceremony [--format human|json] [--quiet] [flags] Offline, append-only orchestration for this repository's BLS12-381 Groth16 -multi-party setup. The binary accepts setup artifacts and signing keys only. -It performs no network access and never selects a mutable "latest" artifact. +multi-party setup. Identity generation is the only production command that +creates a signing key; all operational commands accept an existing local key. +The explicitly rehearsal-only initializer creates same-host test identities. +The binary performs no network access and never selects a mutable "latest" +artifact. Commands: init Bind a ceremony to the compiled repository circuit + identity generate Create a local Ed25519 key and public identity document + rehearsal init Create and initialize a three-party tiny rehearsal + inspect Report chain state and next scheduled contribution phase1 contribute Verify the full phase 1 chain and contribute phase1 attest-erasure Sign a participant destruction attestation phase1 verify Verify and append one candidate contribution @@ -48,6 +54,12 @@ Commands: decision prepare Derive the canonical production GO/NO-GO record decision sign Sign the canonical production GO/NO-GO record decision verify Verify decision evidence and role threshold + inspect definition Authenticate and describe a ceremony definition + inspect chain Authenticate and describe an accepted chain + inspect participant Match an existing key to the participant roster + inspect enrollment Authenticate an operational enrollment + ops prepare-public-witness-receipt Prepare witnessed closure bytes + ops prepare-mirror-receipt Authenticate a relay draft for offline signing ops export-signing Export canonical operational bytes for offline signing ops import-signature Import and verify a raw offline Ed25519 signature ops verify Verify a signed operational record fail-closed @@ -59,6 +71,26 @@ verification-bypass flags. Run "mpc-ceremony help " for command-specific help. ` +var inspectHelp = `Usage: + mpc-ceremony inspect --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-dir DIR [--full] + +Read-only recovery inspection. Reports ceremony identity and mode, per-phase +accepted count and head record, the next scheduled participant and index, the +closure/beacon/seal state, and which referenced artifacts are present. + +It requires no signing key, writes nothing, and never replays contributions. +Unlike every other command it discovers the highest published chain file per +phase; that is safe only because the result feeds no signing or verification +decision, and every discovered file is authenticated against the trust anchor +before being reported. + +The default depth verifies signatures and structure and checks artifact +presence by size in seconds. --full additionally re-verifies every payload +digest, attestation, erasure, and verification record, which re-hashes every +artifact. The output states which depth ran. +` + const replayFlagsHelp = ` Required immutable replay evidence: --transcript-root DIR @@ -78,6 +110,84 @@ second path list. ` var commandHelp = map[string]string{ + "identity": `Usage: + mpc-ceremony identity generate --identity-id ID --display-name NAME \ + --private-key-out FRESH_SECRET_FILE \ + --public-identity-out FRESH_PUBLIC_FILE + +Generate an Ed25519 ceremony signing identity from operating-system CSPRNG +entropy. Run "mpc-ceremony help identity generate" for handling rules. +`, + "identity generate": `Usage: + mpc-ceremony identity generate --identity-id ID --display-name NAME \ + --private-key-out FRESH_SECRET_FILE \ + --public-identity-out FRESH_PUBLIC_FILE + +Generates a new Ed25519 key using the operating-system CSPRNG. The private +output is a proof-tool-compatible hex seed created with mode 0600; keep it on +the trusted machine and never send it to Relay or the coordinator. The public +output is canonical identity JSON containing the public key, its SHA-256 +fingerprint, and an automatically derived key ID. Share only that public file. + +Both parent directories must already exist, both output paths must be distinct, +and neither output may already exist. Private key bytes are never printed. +`, + "rehearsal": `Usage: + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR + +Rehearsal commands create same-host test identities and must never be used as +production enrollment evidence. +`, + "rehearsal init": `Usage: + mpc-ceremony rehearsal init --created-at RFC3339 --out-dir FRESH_DIR + +Creates fresh same-host identities and canonical configuration for exactly +three participants, then initializes a signed rehearsal-tiny-v1 ceremony. The +output is a functional test fixture, not production or independence evidence. +`, + "inspect": inspectHelp + ` +Authenticated record projections are also available as subcommands: + mpc-ceremony inspect [flags] + +These subcommands are read-only and machine-readable. They perform no network +access, replay, signing, or writes. +`, + "inspect definition": `Usage: + mpc-ceremony --format json inspect definition --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY + +Authenticates the exact canonical ceremony definition against the out-of-band +coordinator public key and reports its identity, mode, schedules, and circuit. +`, + "inspect chain": `Usage: + mpc-ceremony --format json inspect chain --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --transcript-root DIR --chain FILE --chain-signature FILE + +Authenticates the definition and accepted chain, validates the chain against +the frozen ceremony, and reports its records and digest-pinned artifacts. It +does not replay contribution payloads. +`, + "inspect participant": `Usage: + mpc-ceremony --format json inspect participant --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --participant-signing-key KEY + +Loads the existing Ed25519 private key with the hardened contribution-key +rules, derives only its public key, and matches it to exactly one identity in +the authenticated participant roster. Reports one-based phase schedule +positions, using null when the participant is absent. It performs no signing or +writes and never emits private-key bytes. +`, + "inspect enrollment": `Usage: + mpc-ceremony --format json inspect enrollment --ceremony FILE \ + --ceremony-signature FILE --coordinator-public-key-file KEY \ + --enrollment FILE --enrollment-signature FILE + +Authenticates the exact canonical operational enrollment and its detached +proof-of-possession signature, then reports an immutable public projection of +the identity, role, role index, timestamp, and independence disclosure. +`, "init": `Usage: mpc-ceremony init --key-version ownership-destination-v2 \ --participants ROSTER.json --policy POLICY.json \ @@ -131,11 +241,18 @@ phase close perform the independent full-prefix replays. mpc-ceremony phase1 close --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --transcript-dir DIR --chain FILE \ --chain-signature FILE --coordinator-signing-key KEY \ - --beacon-round N + --beacon-round N | --beacon-round-lead SECONDS Replays the full phase, derives the exact Quicknet schedule from the round, samples closed_at inside the core after replay, and atomically publishes the signed closure only while the policy lead still holds. + +At K=21 the replay takes hours, so --beacon-round asks you to predict it: a +round named too near is already public when the closure is written and the +whole replay is discarded. --beacon-round-lead instead derives the round from +the clock sampled after the replay, at least SECONDS ahead and never below the +signed witness lead. The round is not published or observable until the closure +record is written either way, so deriving it later commits to nothing sooner. `, "phase1 beacon": `Usage: mpc-ceremony phase1 beacon --ceremony FILE --ceremony-signature FILE \ @@ -201,11 +318,18 @@ Participant contribution and phase close retain independent full replays. --coordinator-public-key-file KEY --phase1-seal FILE \ --phase1-seal-signature FILE --transcript-dir DIR --chain FILE \ --chain-signature FILE --coordinator-signing-key KEY \ - --beacon-round N + --beacon-round N | --beacon-round-lead SECONDS Replays the full phase, derives the exact Quicknet schedule from the round, samples closed_at inside the core after replay, and atomically publishes the signed closure only while the policy lead still holds. + +At K=21 the replay takes hours, so --beacon-round asks you to predict it: a +round named too near is already public when the closure is written and the +whole replay is discarded. --beacon-round-lead instead derives the round from +the clock sampled after the replay, at least SECONDS ahead and never below the +signed witness lead. The round is not published or observable until the closure +record is written either way, so deriving it later commits to nothing sooner. `, "phase2 beacon": `Usage: mpc-ceremony phase2 beacon --ceremony FILE --ceremony-signature FILE \ @@ -226,9 +350,10 @@ Records the distinct Phase 2 post-closure beacon evidence used by finalize. --out-dir FRESH_DIR ` + replayFlagsHelp + ` -Independently compiles this repository's destination-v2 R1CS, replays both -phases, and publishes a coordinator-signed preliminary native PK/VK tree. It -is not a candidate and cannot be audited or released. +Independently compiles the circuit named by the signed ceremony definition +(ownership-destination-v2 in production, rehearsal-tiny-v1 in a rehearsal), +replays both phases, and publishes a coordinator-signed preliminary native +PK/VK tree. It is not a candidate and cannot be audited or released. `, "finalize complete": `Usage: mpc-ceremony finalize complete --ceremony FILE --ceremony-signature FILE \ @@ -309,8 +434,9 @@ the accountable roles should sign. --signing-key KEY --out FRESH_FILE Signs the exact canonical decision bytes with one enrolled ceremony identity. -A GO record requires the coordinator, the two auditors named by the record, -and the distinct release signer to sign the same bytes. Before loading a GO +A GO record requires the coordinator, every auditor named by the record, +and the distinct release signer to sign the same bytes — one signature per +named auditor, so a ceremony with three auditors needs five signatures. Before loading a GO signing key, the command hashes and semantically verifies the full local evidence set. Evidence verification is optional for a NO-GO record so an accountable role can sign a fail-closed decision that reports unavailable @@ -319,7 +445,7 @@ evidence. "decision verify": `Usage: mpc-ceremony decision verify --ceremony FILE --ceremony-signature FILE \ --coordinator-public-key-file KEY --decision FILE \ - --signature FILE --signature FILE --signature FILE --signature FILE \ + --signature FILE [--signature FILE ...] \ --evidence-root DIR Strictly parses the record and detached role signatures, hashes every local @@ -328,11 +454,40 @@ coherence, and fail-closes GO unless all gates PASS and all four roles signed. Evidence URIs are content bindings only; the command performs no network fetch. `, "ops": `Usage: - mpc-ceremony ops [flags] + mpc-ceremony ops [flags] Operational records cover proof-of-possession enrollment, transfers and receipts, immutable mirrors, pre-beacon public witnesses, multi-operator relay evidence, governance events, and the release-bound operational evidence bundle. +`, + "ops prepare-public-witness-receipt": `Usage: + mpc-ceremony ops prepare-public-witness-receipt \ + --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root DIR \ + --closure FILE --closure-signature FILE \ + --witness-enrollment FILE --witness-enrollment-signature FILE \ + --publication-location URI --observed-at RFC3339_UTC \ + --out-dir FRESH_DIR + +Authenticates the ceremony, coordinator-signed closure, and public-witness +proof-of-possession enrollment. It validates the human-claimed observation +against the signed closure and beacon schedule, hashes the publication location, +and exports canonical.json plus signing-request.json for offline review and +signing. The program validates coherence; it does not claim to have observed +publication itself and never reads the witness private key. +`, + "ops prepare-mirror-receipt": `Usage: + mpc-ceremony ops prepare-mirror-receipt --draft FILE \ + --ceremony FILE --ceremony-signature FILE \ + --coordinator-public-key-file KEY --transcript-root DIR \ + --chain FILE --chain-signature FILE \ + --mirror-enrollment FILE --mirror-enrollment-signature FILE \ + --out-dir FRESH_DIR + +Authenticates the exact accepted chain prefix and the mirror operator's signed +proof-of-possession enrollment, recomputes every receipt file reference, and +requires the relay draft to match. It then exports canonical.json and +signing-request.json without reading a private signing key. `, "ops export-signing": `Usage: mpc-ceremony ops export-signing --record-type TYPE --record FILE \ diff --git a/cmd/wasm-prover/main_js.go b/cmd/wasm-prover/main_js.go index 45cbd3f..ccee977 100644 --- a/cmd/wasm-prover/main_js.go +++ b/cmd/wasm-prover/main_js.go @@ -12,6 +12,7 @@ import ( "fmt" "hash" "io" + "math" "net/http" "net/url" "os" @@ -1009,7 +1010,15 @@ func openConstraintSystem(req artifactRequest, manifest *artifact.KeyManifest, c if expectedCCSAsset != nil && expectedCCSAsset.Compressed != nil && expectedCCSAsset.Compressed.Encoding == "zstd" { compressedPin = expectedCCSAsset.Compressed } - ccs, digest, encoding, err := fetchCCSPreferCompressed(ccsURL, compressedPin) + // The decoded CCS size is pinned by the signed manifest whenever an asset + // pin is present; use it to bound the decoder's reads (and, for the + // compressed variant, the zstd inflate) so a hostile or corrupt object + // cannot stream unbounded bytes. Without a pin, fall back to a coarse cap. + maxDecoded := int64(maxCCSDecodedBytes) + if expectedCCSAsset != nil && expectedCCSAsset.Size > 0 { + maxDecoded = expectedCCSAsset.Size + } + ccs, digest, encoding, err := fetchCCSPreferCompressed(ccsURL, compressedPin, maxDecoded) if err != nil { return nil, err } @@ -1054,13 +1063,52 @@ type ccsLoadStats struct { var lastCCSLoadStats ccsLoadStats +// maxCCSDecodedBytes bounds the decoded constraint system when no signed size +// pin is available. The ownership CCS is ~129 MiB; 2 GiB is a generous ceiling +// that still fits a wasm32 address space and rejects an unbounded stream. +const maxCCSDecodedBytes = 1 << 31 + +// zstdMaxMemory returns a decoder window-memory ceiling proportional to the +// expected decoded size, clamped to a sane floor so small objects still +// decode. The decoder never needs more window than the object it produces. +func zstdMaxMemory(maxDecoded int64) uint64 { + const floor = 1 << 26 // 64 MiB + if maxDecoded < floor { + return floor + } + return uint64(maxDecoded) +} + +func boundedCompressedWire(r io.Reader, size int64) (io.Reader, error) { + if r == nil { + return nil, fmt.Errorf("compressed ccs reader is required") + } + if size <= 0 || size == math.MaxInt64 { + return nil, fmt.Errorf("compressed ccs size %d cannot be bounded", size) + } + return io.LimitReader(r, size+1), nil +} + +// safeCCSReadFrom converts ordinary decoder panics into errors. Allocation +// safety does not rely on recover: PreflightConstraintSystemReader rejects the +// declared payload length before gnark can allocate from it. +func safeCCSReadFrom(ccs constraint.ConstraintSystem, r io.Reader) (err error) { + defer func() { + if rec := recover(); rec != nil { + err = fmt.Errorf("constraint system decode panicked: %v", rec) + } + }() + _, err = ccs.ReadFrom(r) + return err +} + // fetchCCSPreferCompressed fetches the CCS via its pinned zstd transport // variant when one is supplied, falling back to the identity URL when the // compressed object is unavailable. The returned digest is always over the // DECODED bytes, so the caller's checks against the identity pin are // unchanged. A compressed-digest mismatch fails closed — the pin is signed, // so wrong bytes are tamper evidence, not a transport hiccup. -func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedAssetPin) (constraint.ConstraintSystem, prover.FileDigest, string, error) { +func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedAssetPin, maxDecoded int64) (constraint.ConstraintSystem, prover.FileDigest, string, error) { if compressed != nil { // A query-carrying ccs_url (signed URL, cache buster) cannot yield a // valid sibling URL — the token belongs to the identity object — so @@ -1074,7 +1122,7 @@ func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedA if err != nil { return nil, prover.FileDigest{}, "", fmt.Errorf("resolve compressed ccs url: %w", err) } - ccs, digest, err := fetchCCS(compressedURL, compressed) + ccs, digest, err := fetchCCS(compressedURL, compressed, maxDecoded) if err == nil { return ccs, digest, "zstd", nil } @@ -1084,7 +1132,7 @@ func fetchCCSPreferCompressed(ccsURL string, compressed *proofassets.CompressedA } msmengine.EmitTrace("measure", "open-ccs-compressed-fallback", map[string]any{"error": err.Error()}) } - ccs, digest, err := fetchCCS(ccsURL, nil) + ccs, digest, err := fetchCCS(ccsURL, nil, maxDecoded) return ccs, digest, "identity", err } @@ -1115,7 +1163,7 @@ func (e *assetUnavailableError) Unwrap() error { return e.err } // frame: the wire bytes are hashed and length-checked against the pin while // the decoder inflates them, and the decoded stream is hashed for the // caller's identity-pin checks. -func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin) (constraint.ConstraintSystem, prover.FileDigest, error) { +func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin, maxDecoded int64) (constraint.ConstraintSystem, prover.FileDigest, error) { requestStarted := time.Now() resp, err := http.Get(rawURL) if err != nil { @@ -1145,8 +1193,18 @@ func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin) (constr if err != nil { return nil, prover.FileDigest{}, fmt.Errorf("create blake2b digest: %w", err) } - wire = &countingReader{r: io.TeeReader(body, io.MultiWriter(wireSHA, wireBlake))} - zstdDecoder, err = zstd.NewReader(wire) + // The signed compressed size is known before transport. Read at most one + // byte beyond it so an oversized or endless response fails without being + // drained to EOF first. + boundedWire, err := boundedCompressedWire(body, compressed.Size) + if err != nil { + return nil, prover.FileDigest{}, err + } + wire = &countingReader{r: io.TeeReader(boundedWire, io.MultiWriter(wireSHA, wireBlake))} + // Bound the decoder's window memory. klauspost's default is 64 GiB, so + // without this a tiny frame declaring a huge window is itself a memory + // bomb, independent of how much output we read. + zstdDecoder, err = zstd.NewReader(wire, zstd.WithDecoderMaxMemory(zstdMaxMemory(maxDecoded))) if err != nil { return nil, prover.FileDigest{}, fmt.Errorf("create zstd decoder: %w", err) } @@ -1155,12 +1213,24 @@ func fetchCCS(rawURL string, compressed *proofassets.CompressedAssetPin) (constr } else { decoded = body } - reader := &countingReader{r: io.TeeReader(decoded, hashes)} + // Cap the decoded byte count at the pinned size (plus one, to detect + // overrun). Preflight the fixed gnark header below as well: LimitReader caps + // transport, but gnark allocates from its declared length before reading the + // payload. + if maxDecoded < 1 { + maxDecoded = maxCCSDecodedBytes + } + limited := io.LimitReader(decoded, maxDecoded+1) + reader := &countingReader{r: io.TeeReader(limited, hashes)} ccs := groth16.NewCS(ecc.BLS12_381) decodeStarted := time.Now() bodyBefore, hashBefore := body.duration, hashes.duration - if _, err := ccs.ReadFrom(reader); err != nil { + ccsReader, err := prover.PreflightConstraintSystemReader(reader, maxDecoded) + if err == nil { + err = safeCCSReadFrom(ccs, ccsReader) + } + if err != nil { err = fmt.Errorf("read constraint system: %w", err) if compressed != nil { // A truncated frame or mid-body reset on the compressed object is diff --git a/cmd/wasm-prover/main_js_test.go b/cmd/wasm-prover/main_js_test.go new file mode 100644 index 0000000..9a40c83 --- /dev/null +++ b/cmd/wasm-prover/main_js_test.go @@ -0,0 +1,37 @@ +//go:build js && wasm + +package main + +import ( + "bytes" + "io" + "math" + "strings" + "testing" +) + +func TestBoundedCompressedWireAllowsOneByteForOverrunDetection(t *testing.T) { + source := bytes.NewReader([]byte("0123456789")) + r, err := boundedCompressedWire(source, 4) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if string(got) != "01234" { + t.Fatalf("bounded bytes = %q, want %q", got, "01234") + } + if source.Len() != 5 { + t.Fatalf("bounded reader consumed %d bytes past its cap", 5-source.Len()) + } +} + +func TestBoundedCompressedWireRejectsUnsafeSizes(t *testing.T) { + for _, size := range []int64{0, -1, math.MaxInt64} { + if _, err := boundedCompressedWire(strings.NewReader("x"), size); err == nil { + t.Fatalf("size %d was accepted", size) + } + } +} diff --git a/docs/README.md b/docs/README.md index 0819617..18728e6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -22,19 +22,18 @@ that foundation. - [`trusted-setup-ceremony.md`](trusted-setup-ceremony.md): setup provenance and signed key-bundle handling, including the explicit boundary between local single-actor setup and MPC. -- [`mpc-ceremony-runbook.md`](mpc-ceremony-runbook.md): production operator, - contributor, auditor, beacon, archival, replay, and release gates for the - dedicated two-phase BLS12-381 MPC ceremony. -- [`mpc-production-readiness.md`](mpc-production-readiness.md): the formal - mainnet go/no-go matrix, current **NO-GO**, blocking rehearsal incident, and - required evidence package for that ceremony. -- [`mpc-security-review.md`](mpc-security-review.md): pinned dependency - advisory dispositions, reviewed defenses, and independent review gates. -- [`mpc-external-audit-package.md`](mpc-external-audit-package.md): frozen - review scope, required independent tests, and auditor deliverables. -- [`mpc-production-go-no-go-template.md`](mpc-production-go-no-go-template.md): - exact mainnet ceremony, external, coherence, and accountable-signature - acceptance record. +- [`mpc-ceremony-parallel-optimizations.md`](mpc-ceremony-parallel-optimizations.md): + gnark Phase 1/Phase 2 threading changes, safety invariants, benchmarks, and + the initial exact K=21 comparison result. +- [`mpc-ceremony-release.md`](mpc-ceremony-release.md): independent + `mpc-ceremony` release gates and the downstream binary-pair compatibility + boundary. +- Relay's + [coordinator runbook](https://github.com/zksecurity/relay/blob/main/COORDINATOR_RUNBOOK.md) + and [role runbook](https://github.com/zksecurity/relay/blob/main/ROLE_RUNBOOK.md): + participant, witness, mirror, auditor, beacon, archival, and release + operations. The bundled Relay rehearsal is test-only; a production ceremony + requires its own independently reviewed go/no-go record. - [`proof-assets-release-inventory.md`](proof-assets-release-inventory.md): the current release identity and coherence values. @@ -74,11 +73,6 @@ that foundation. These remain plans because their external acceptance gates are still open: -- [`production-readiness.md`](production-readiness.md): current Mainnet - readiness verdict, evidence boundary, scorecard, and release gates. -- [`next-steps-to-mainnet.md`](next-steps-to-mainnet.md): status ledger for the - original readiness task IDs, distinguishing tracked, working-tree, external, - and open work. - [`circuit-proving-optimization-candidates.md`](circuit-proving-optimization-candidates.md): refreshed circuit/runtime optimization survey and current baselines. - [`manual-lace-claim-flow-qa-plan.md`](manual-lace-claim-flow-qa-plan.md): diff --git a/docs/mpc-ceremony-parallel-optimizations.md b/docs/mpc-ceremony-parallel-optimizations.md new file mode 100644 index 0000000..a91a917 --- /dev/null +++ b/docs/mpc-ceremony-parallel-optimizations.md @@ -0,0 +1,260 @@ +# MPC Ceremony Parallel Optimizations + +This note explains the three multithreading optimizations applied to gnark's +BLS12-381 Groth16 MPC implementation for the proof-tool ceremony. They change +how independent work is scheduled; they do not change the circuit, proof +statement, elliptic-curve formulas, transcript layout, or verification rules. + +The implementation is carried as reviewed patches against the repository's +pinned gnark v0.15.0 dependency: + +- `experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch` +- `experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch` +- `experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch` + +`scripts/bootstrap-vendor.sh` applies these patches when reconstructing the +gitignored `vendor/` tree. Vendor drift checks, release metadata, and SBOM +generation include all three patches. + +## Results Summary + +Controlled benchmarks were run on a 16-vCPU AMD EPYC host. The benchmark +fixtures use smaller domains than the complete ceremony, so these figures are +component measurements rather than end-to-end K=21 predictions. + +| Hot path | Serial median | Parallel median | Speedup | +|---|---:|---:|---:| +| Phase 1 point update | 574 ms | 50.8 ms | 11.3× | +| Phase 1 encoding | 8.01 ms | 1.79 ms | 4.5× | +| Phase 1 decoding | 1.33 s | 151 ms | 8.9× | +| Phase 2 initialization | 9.97 s | 1.14 s | 8.7× | + +The first result from the exact K=21 comparison rehearsal is: + +| Exact K=21 stage | Previous run | Optimized run | Speedup | +|---|---:|---:|---:| +| Ceremony initialization | 12m16s | 1m34.76s | 7.8× | +| Phase 1 contribution (16 vCPU) | 56m54s | 7m02s | 8.1× | +| Phase 1 contribution (8 vCPU) | 56m54s | 8m01s | 7.1× | +| Candidate verification (accept) | 50m27s | 5m46s | 8.7× | + +The optimized initialization averaged 954% CPU according to GNU `time`, which +means it used about 9.54 CPU cores concurrently. It reached a peak resident set +size of approximately 3.38 GiB. + +The contribution and verification rows were measured on 2026-08-20 during a +Relay-driven production-mode first-head test at the canonical circuit +(1,789,750 constraints): the 16-vCPU contribution ran on the same EPYC host +class as the serial baselines, the 8-vCPU contribution ran on a separate role +machine through `relay participate` (66m37s of CPU in 8m01s of wall clock), +and verification ran through the coordinator accept path (67m00s of CPU in +5m46s). The serial baselines are the corresponding stages of the completed +single-host K=21 production-mode run measured before these patches. + +## 1. Parallel Phase 1 Point Updates + +Each Phase 1 contribution updates millions of SRS points using fresh secret +scalars tau-prime, alpha-prime, and beta-prime. At index `i`, the work is +conceptually: + +```text +Tau[i] = Tau[i] * tau-prime^i +AlphaTau[i] = AlphaTau[i] * alpha-prime * tau-prime^i +BetaTau[i] = BetaTau[i] * beta-prime * tau-prime^i +``` + +### Previous behavior + +One thread walked every index in sequence. It maintained one running power of +tau-prime and performed all G1 and G2 scalar multiplications serially. + +### Parallel behavior + +The updated implementation divides the arrays into disjoint ranges. Each +worker: + +1. Computes `tau-prime^start` for the first index in its range. +2. Updates only the points in that range. +3. Advances its own local power of tau-prime after each point. + +The expensive first range, which updates G1 Tau, G2 Tau, AlphaTau, and +BetaTau, is scheduled separately from the lighter G1-Tau-only tail. This +prevents the lighter tail from distorting load balancing for the four-operation +range. + +```text +worker 0: [start 0 ................................ end 0) +worker 1: [start 1 ........ end 1) +worker 2: [start 2 ... end 2) +``` + +### Correctness and race safety + +- Worker ranges never overlap. +- Each worker owns its field elements and `big.Int` temporaries. +- Every index receives the same scalar as in the original serial loop. +- Alpha, beta, and the index-zero values retain their original handling. +- Equivalence tests compare the complete parallel SRS with the retained serial + reference implementation. +- The Go race detector passes for the patched package. + +## 2. Parallel Phase 1 Encoding and Decoding + +An exact K=21 Phase 1 artifact is approximately 576 MiB and contains millions +of compressed G1 and G2 points. + +### Previous behavior + +Gnark constructed a large `[]any` containing an individual reference to every +point. Its generic encoder or decoder then processed those references one at a +time. Point decompression, curve checks, and subgroup checks therefore ran +serially. + +### Parallel behavior + +The new codec processes bounded chunks of 4,096 points. + +Encoding: + +```text +compress each point concurrently into its fixed byte offset + | + v +write the completed chunk in canonical point order +``` + +Decoding: + +```text +read one fixed-width canonical chunk in point order + | + v +decode and validate each point concurrently +``` + +Chunks themselves are always read and written sequentially, so scheduling +cannot reorder artifact bytes. + +### Wire format and validation + +The encoded layout remains: + +```text +N +G2 Beta +G1 Tau[1:] +G2 Tau[1:] +G1 BetaTau +G1 AlphaTau +``` + +Every decoded point still undergoes: + +- canonical field-element decoding; +- curve membership validation; +- subgroup validation; and +- deterministic error selection in point order. + +The new reader consumes fixed compressed widths and therefore rejects +uncompressed encodings that gnark's generic decoder previously could consume. +This is intentional for proof-tool's strict canonical-artifact boundary, but it +is a behavior change in the patched gnark `SrsCommons.ReadFrom` method and must +remain explicitly documented and tested. + +Temporary codec memory is bounded by one chunk rather than scaling with the +number of SRS point references. + +## 3. Parallel Phase 2 Initialization + +Phase 2 derives circuit-specific Groth16 parameters from the sealed Phase 1 SRS +and the compiled R1CS. The optimization covers three areas. + +### 3.1 Lagrange group FFTs + +Initialization computes four large group transforms: + +- Tau in G1; +- Tau in G2; +- AlphaTau in G1; and +- BetaTau in G1. + +Gnark's previous recursive FFT split its two recursive halves across workers, +but each recursion node first completed a large butterfly pass serially. The +largest top-level pass therefore delayed all recursive parallelism. + +The new implementation parallelizes the butterfly pass itself under a fixed +CPU budget: + +```text +stage 0: 1 branch x 16 workers +stage 1: 2 branches x 8 workers +stage 2: 4 branches x 4 workers +stage 3: 8 branches x 2 workers +stage 4: 16 branches x 1 worker +``` + +Each butterfly operates on a distinct pair of points. No two workers write the +same point during a stage, and the total intended concurrency remains bounded +by the selected worker budget. + +### 3.2 Constraint accumulation + +Each R1CS constraint contributes to A, B, and C evaluations associated with +particular wires. Parallelizing constraints directly would allow multiple +workers to mutate the same wire. + +The implementation instead: + +1. Reads constraints sequentially in bounded batches of 16,384. +2. Assigns each wire to exactly one worker using `wireID % workerCount`. +3. Queues left, right, and output terms for the owning worker. +4. Processes those queues concurrently. +5. Completes the batch before reading the next one. + +Terms for a particular wire and expression side retain their original order. +Because a wire has exactly one owner, no locks are required and workers cannot +race on the output arrays. + +### 3.3 Independent point loops + +Two additional loops operate on independent output points and are now +parallel: + +- construction of the Z polynomial points; and +- computation of `beta*A + alpha*B + C` for every wire. + +## Security and Determinism Invariants + +The optimizations are designed around the following invariants: + +- Transcript and SRS bytes must not depend on goroutine scheduling. +- Workers may read shared immutable data but must own every point they mutate. +- Point decoding must retain canonical, curve, and subgroup validation. +- Additional memory must remain bounded at K=21. +- The pinned gnark version and all local patches must be represented in build + provenance and SBOM evidence. +- Serial and parallel implementations must produce byte-identical outputs for + deterministic fixtures. + +Focused equivalence and negative tests, the race detector, ceremony integration +tests, and vendor regeneration/drift checks pass. + +The equivalence fixtures use deterministic, distinct curve points. The Phase 2 +test compares both one- and four-worker execution with a retained copy of +gnark v0.15.0's serial initialization algorithm, including its serial group +FFTs. It also asserts that the fixture's inverse FFT is dense, preventing a +constant-vector delta from making most accumulation operations no-ops. The +codec test crosses the 4,096-point chunk boundary with a different point at +every serialized position, and a negative test records the intentional +rejection of otherwise valid uncompressed Phase 1 points. + +## Remaining Review Follow-up + +Execute FFT butterfly loops directly when a recursion node has only one +assigned task, avoiding unnecessary one-worker goroutine creation. This is a +small scheduling cleanup rather than a correctness requirement. + +The full exact K=21 comparison rehearsal remains the final performance and +coherence check. Its ceremony binary is pinned independently by SHA-256, and +its measurements record wall time, CPU time, peak memory, filesystem activity, +and exact command outputs for every stage. diff --git a/docs/mpc-ceremony-release.md b/docs/mpc-ceremony-release.md new file mode 100644 index 0000000..8abe771 --- /dev/null +++ b/docs/mpc-ceremony-release.md @@ -0,0 +1,35 @@ +# Releasing `mpc-ceremony` + +`mpc-ceremony` is an independently released ceremony engine. Its release gate +must not check out, pin, or depend on a Relay source commit. This keeps the +ceremony parser, cryptographic implementation, and release decision owned by +proof-tool. + +The repository's `MPC ceremony release validation` workflow checks the +following proof-tool properties: + +- the approved Go toolchain and module identity; +- the patched vendor tree; +- two byte-for-byte reproducible unsigned rehearsal packages; +- the downloadable tiny rehearsal initializer and authenticated definition + projection; and +- absence of production signatures from rehearsal packages. + +Production release maintainers additionally follow +`scripts/build-mpc-ceremony-release.sh` and +`scripts/verify-mpc-ceremony-reproducible.sh` using the approved signed tag and +offline build-signing key. Publish the standalone `mpc-ceremony` binary and its +complete verification package through proof-tool's release process. + +## Coordinated distribution + +Compatibility with Relay is tested after both projects have released +independently. The ceremony-kit process receives the exact approved Relay and +`mpc-ceremony` repositories, tags, binaries, and SHA-256 hashes. It runs the +binary-only tiny-rehearsal compatibility gate, including a real phase 1 +contribution, erasure attestation, coordinator acceptance, and accepted-chain +inspection, and records the tested hashes in the kit's `compatibility.json`. + +That downstream gate may reject a proposed pairing without invalidating either +independent release. Updating Relay never requires changing proof-tool's CI, +and releasing proof-tool never requires selecting a Relay commit. diff --git a/docs/proof-helper-windows-release-runbook.md b/docs/proof-helper-windows-release-runbook.md index dfbbb9b..616bca2 100644 --- a/docs/proof-helper-windows-release-runbook.md +++ b/docs/proof-helper-windows-release-runbook.md @@ -108,8 +108,7 @@ Dispatch the workflow from a clean pushed commit: gh workflow run release-proof-helper.yml \ --ref main \ -f tag=proof-helper-desktop-v0.1.0-windows-preview.1 \ - -f publish_release=false \ - -f signed_release=false + -f publish_release=false ``` Watch the run: @@ -131,6 +130,11 @@ The workflow: `proof-helper-windows-release-manifest.json`. 6. Creates a draft prerelease unless `publish_release=true`. +This workflow can only produce an unsigned preview. It does not accept a flag +that marks artifacts as signed, because neither the workflow nor its staging +script performs or verifies Authenticode signing. A future signed release path +must set release metadata from successful signature verification. + ## Local Windows Build The canonical release path is still the GitHub Actions workflow above. Use this @@ -206,7 +210,7 @@ Check the manifest fields before testing: - `sidecar.sha256` is present. - `proof_assets_descriptor.download_configured` matches the intended release status. -- `signed` matches the Authenticode status that was actually verified. +- `signed` is `false`; this workflow only stages unsigned previews. ## Windows Validation diff --git a/docs/trusted-setup-ceremony.md b/docs/trusted-setup-ceremony.md index debbe88..1874c7b 100644 --- a/docs/trusted-setup-ceremony.md +++ b/docs/trusted-setup-ceremony.md @@ -4,12 +4,19 @@ This repository has two deliberately separate Groth16 setup paths: - `proof-tool setup-ceremony` is a reproducible, signed, single-actor local setup. -- `cmd/mpc-ceremony` is the two-phase multi-party workflow whose production - process is documented in - [`mpc-ceremony-runbook.md`](mpc-ceremony-runbook.md). +- `cmd/mpc-ceremony` is the two-phase multi-party engine. Relay's + [coordinator runbook](https://github.com/zksecurity/relay/blob/main/COORDINATOR_RUNBOOK.md) + and [role runbook](https://github.com/zksecurity/relay/blob/main/ROLE_RUNBOOK.md) + document the distributed transport and operator workflow. The commands, transcripts, and trust claims are not interchangeable. +The `mpc-ceremony` binary is also released independently of transport tools. +Its reproducibility and CLI checks do not fetch or pin a Relay commit. A +coordinated ceremony kit selects independently verified releases, tests the +exact binaries together, and records their hashes as described in +[`mpc-ceremony-release.md`](mpc-ceremony-release.md). + ## Single-Actor Local Setup Run the local path with: @@ -60,11 +67,13 @@ ordered contributions in both phases, uses separate future public beacons for Phase 1 and Phase 2, and supports full independent transcript replay. Software verification alone is still insufficient: participant independence, host controls, entropy quality, erasure, public archival, and independent audits are -operational requirements. See the full -[`MPC ceremony operator, contributor, and auditor runbook`](mpc-ceremony-runbook.md). -Its current Mainnet decision is **NO-GO**; see -[`mpc-production-readiness.md`](mpc-production-readiness.md) before using any -ceremony binary or artifact. +operational requirements. See Relay's +[coordinator runbook](https://github.com/zksecurity/relay/blob/main/COORDINATOR_RUNBOOK.md) +and [role runbook](https://github.com/zksecurity/relay/blob/main/ROLE_RUNBOOK.md) +for the deployed workflow. Relay's bundled rehearsal is test-only and does not +constitute production approval; each production ceremony requires an explicit, +independently reviewed go/no-go record before any ceremony binary or artifact +is used. ## Toxic Waste Handling diff --git a/experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch b/experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch new file mode 100644 index 0000000..894210b --- /dev/null +++ b/experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch @@ -0,0 +1,456 @@ +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/marshal.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/marshal.go +@@ -152,56 +152,170 @@ + return n + dn, err + } + +-// refsSlice produces a slice consisting of references to all sub-elements +-// prepended by the size parameter, to be used in WriteTo and ReadFrom functions +-func (c *SrsCommons) refsSlice() []any { +- N := uint64(len(c.G2.Tau)) +- expectedLen := 5*N - 1 +- // size N 1 +- // [β]₂ 1 +- // [τⁱ]₁ for 1 ≤ i ≤ 2N-2 2N-2 +- // [τⁱ]₂ for 1 ≤ i ≤ N-1 N-1 +- // [ατⁱ]₁ for 0 ≤ i ≤ N-1 N +- // [βτⁱ]₁ for 0 ≤ i ≤ N-1 N +- refs := make([]any, 2, expectedLen) +- refs[0] = N +- refs[1] = &c.G2.Beta +- refs = utils.AppendRefs(refs, c.G1.Tau[1:]) +- refs = utils.AppendRefs(refs, c.G2.Tau[1:]) +- refs = utils.AppendRefs(refs, c.G1.BetaTau) +- refs = utils.AppendRefs(refs, c.G1.AlphaTau) +- +- if uint64(len(refs)) != expectedLen { +- panic("incorrect length estimate") +- } +- +- return refs +-} +- +-func (c *SrsCommons) WriteTo(writer io.Writer) (int64, error) { +- enc := curve.NewEncoder(writer) +- for _, v := range c.refsSlice() { +- if err := enc.Encode(v); err != nil { +- return enc.BytesWritten(), err ++const srsCodecChunkSize = 4096 ++ ++// WriteTo writes the canonical Phase 1 wire format. Point compression is done ++// in bounded parallel chunks, while chunks themselves are written in order. ++func (c *SrsCommons) WriteTo(writer io.Writer) (n int64, err error) { ++ var size [8]byte ++ binary.BigEndian.PutUint64(size[:], uint64(len(c.G2.Tau))) ++ if dn, writeErr := writeAll(writer, size[:]); writeErr != nil { ++ return int64(dn), writeErr ++ } ++ n = int64(len(size)) ++ ++ for _, write := range []func(io.Writer) (int64, error){ ++ func(w io.Writer) (int64, error) { return writeG2Point(w, &c.G2.Beta) }, ++ func(w io.Writer) (int64, error) { return writeG1Points(w, c.G1.Tau[1:]) }, ++ func(w io.Writer) (int64, error) { return writeG2Points(w, c.G2.Tau[1:]) }, ++ func(w io.Writer) (int64, error) { return writeG1Points(w, c.G1.BetaTau) }, ++ func(w io.Writer) (int64, error) { return writeG1Points(w, c.G1.AlphaTau) }, ++ } { ++ dn, writeErr := write(writer) ++ n += dn ++ if writeErr != nil { ++ return n, writeErr + } + } +- return enc.BytesWritten(), nil ++ return n, nil + } + +-// ReadFrom implements io.ReaderFrom ++// ReadFrom implements io.ReaderFrom. Each point still undergoes the full ++// canonical encoding, curve, and subgroup checks performed by SetBytes. + func (c *SrsCommons) ReadFrom(reader io.Reader) (n int64, err error) { +- var N uint64 +- dec := curve.NewDecoder(reader) +- if err = dec.Decode(&N); err != nil { +- return dec.BytesRead(), err ++ var size [8]byte ++ dn, err := io.ReadFull(reader, size[:]) ++ n = int64(dn) ++ if err != nil { ++ return n, err + } + ++ N := binary.BigEndian.Uint64(size[:]) + c.setContributionsZero(N) + +- for _, v := range c.refsSlice()[1:] { // we've already decoded N +- if err = dec.Decode(v); err != nil { +- return dec.BytesRead(), err ++ for _, read := range []func(io.Reader) (int64, error){ ++ func(r io.Reader) (int64, error) { return readG2Point(r, &c.G2.Beta) }, ++ func(r io.Reader) (int64, error) { return readG1Points(r, c.G1.Tau[1:]) }, ++ func(r io.Reader) (int64, error) { return readG2Points(r, c.G2.Tau[1:]) }, ++ func(r io.Reader) (int64, error) { return readG1Points(r, c.G1.BetaTau) }, ++ func(r io.Reader) (int64, error) { return readG1Points(r, c.G1.AlphaTau) }, ++ } { ++ dn, readErr := read(reader) ++ n += dn ++ if readErr != nil { ++ return n, readErr ++ } ++ } ++ return n, nil ++} ++ ++func writeAll(writer io.Writer, data []byte) (int, error) { ++ written := 0 ++ for len(data) > 0 { ++ n, err := writer.Write(data) ++ written += n ++ data = data[n:] ++ if err != nil { ++ return written, err ++ } ++ if n == 0 { ++ return written, io.ErrShortWrite ++ } ++ } ++ return written, nil ++} ++ ++func writeG1Points(writer io.Writer, points []curve.G1Affine) (n int64, err error) { ++ for start := 0; start < len(points); start += srsCodecChunkSize { ++ end := min(start+srsCodecChunkSize, len(points)) ++ chunk := make([]byte, (end-start)*curve.SizeOfG1AffineCompressed) ++ utils.Parallelize(end-start, func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ encoded := points[start+i].Bytes() ++ copy(chunk[i*curve.SizeOfG1AffineCompressed:], encoded[:]) ++ } ++ }) ++ dn, writeErr := writeAll(writer, chunk) ++ n += int64(dn) ++ if writeErr != nil { ++ return n, writeErr ++ } ++ } ++ return n, nil ++} ++ ++func writeG2Points(writer io.Writer, points []curve.G2Affine) (n int64, err error) { ++ for start := 0; start < len(points); start += srsCodecChunkSize { ++ end := min(start+srsCodecChunkSize, len(points)) ++ chunk := make([]byte, (end-start)*curve.SizeOfG2AffineCompressed) ++ utils.Parallelize(end-start, func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ encoded := points[start+i].Bytes() ++ copy(chunk[i*curve.SizeOfG2AffineCompressed:], encoded[:]) ++ } ++ }) ++ dn, writeErr := writeAll(writer, chunk) ++ n += int64(dn) ++ if writeErr != nil { ++ return n, writeErr ++ } ++ } ++ return n, nil ++} ++ ++func writeG2Point(writer io.Writer, point *curve.G2Affine) (int64, error) { ++ encoded := point.Bytes() ++ n, err := writeAll(writer, encoded[:]) ++ return int64(n), err ++} ++ ++func readG1Points(reader io.Reader, points []curve.G1Affine) (n int64, err error) { ++ return readPointChunks(reader, len(points), curve.SizeOfG1AffineCompressed, func(start int, chunk []byte, decodeErrs []error) { ++ utils.Parallelize(len(decodeErrs), func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ _, decodeErrs[i] = points[start+i].SetBytes(chunk[i*curve.SizeOfG1AffineCompressed : (i+1)*curve.SizeOfG1AffineCompressed]) ++ } ++ }) ++ }) ++} ++ ++func readG2Points(reader io.Reader, points []curve.G2Affine) (n int64, err error) { ++ return readPointChunks(reader, len(points), curve.SizeOfG2AffineCompressed, func(start int, chunk []byte, decodeErrs []error) { ++ utils.Parallelize(len(decodeErrs), func(workerStart, workerEnd int) { ++ for i := workerStart; i < workerEnd; i++ { ++ _, decodeErrs[i] = points[start+i].SetBytes(chunk[i*curve.SizeOfG2AffineCompressed : (i+1)*curve.SizeOfG2AffineCompressed]) ++ } ++ }) ++ }) ++} ++ ++func readG2Point(reader io.Reader, point *curve.G2Affine) (int64, error) { ++ var encoded [curve.SizeOfG2AffineCompressed]byte ++ n, err := io.ReadFull(reader, encoded[:]) ++ if err != nil { ++ return int64(n), err ++ } ++ _, err = point.SetBytes(encoded[:]) ++ return int64(n), err ++} ++ ++func readPointChunks(reader io.Reader, count, pointSize int, decode func(int, []byte, []error)) (n int64, err error) { ++ for start := 0; start < count; start += srsCodecChunkSize { ++ end := min(start+srsCodecChunkSize, count) ++ chunk := make([]byte, (end-start)*pointSize) ++ dn, readErr := io.ReadFull(reader, chunk) ++ n += int64(dn) ++ if readErr != nil { ++ return n, readErr ++ } ++ ++ decodeErrs := make([]error, end-start) ++ decode(start, chunk, decodeErrs) ++ for _, decodeErr := range decodeErrs { ++ if decodeErr != nil { ++ return n, decodeErr ++ } + } + } +- return dec.BytesRead(), nil ++ return n, nil + } +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/marshal_parallel_test.go +@@ -0,0 +1,239 @@ ++package mpcsetup ++ ++import ( ++ "bytes" ++ "encoding/binary" ++ "io" ++ "reflect" ++ "testing" ++ ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++) ++ ++func writeCommonsSerial(c *SrsCommons, writer io.Writer) (int64, error) { ++ enc := curve.NewEncoder(writer) ++ if err := enc.Encode(uint64(len(c.G2.Tau))); err != nil { ++ return enc.BytesWritten(), err ++ } ++ values := []any{&c.G2.Beta} ++ for i := 1; i < len(c.G1.Tau); i++ { ++ values = append(values, &c.G1.Tau[i]) ++ } ++ for i := 1; i < len(c.G2.Tau); i++ { ++ values = append(values, &c.G2.Tau[i]) ++ } ++ for i := range c.G1.BetaTau { ++ values = append(values, &c.G1.BetaTau[i]) ++ } ++ for i := range c.G1.AlphaTau { ++ values = append(values, &c.G1.AlphaTau[i]) ++ } ++ for _, value := range values { ++ if err := enc.Encode(value); err != nil { ++ return enc.BytesWritten(), err ++ } ++ } ++ return enc.BytesWritten(), nil ++} ++ ++func readCommonsSerial(c *SrsCommons, reader io.Reader) (int64, error) { ++ dec := curve.NewDecoder(reader) ++ var n uint64 ++ if err := dec.Decode(&n); err != nil { ++ return dec.BytesRead(), err ++ } ++ c.setContributionsZero(n) ++ values := []any{&c.G2.Beta} ++ for i := 1; i < len(c.G1.Tau); i++ { ++ values = append(values, &c.G1.Tau[i]) ++ } ++ for i := 1; i < len(c.G2.Tau); i++ { ++ values = append(values, &c.G2.Tau[i]) ++ } ++ for i := range c.G1.BetaTau { ++ values = append(values, &c.G1.BetaTau[i]) ++ } ++ for i := range c.G1.AlphaTau { ++ values = append(values, &c.G1.AlphaTau[i]) ++ } ++ for _, value := range values { ++ if err := dec.Decode(value); err != nil { ++ return dec.BytesRead(), err ++ } ++ } ++ return dec.BytesRead(), nil ++} ++ ++func codecTestCommons(tb testing.TB, n uint64) SrsCommons { ++ tb.Helper() ++ var c SrsCommons ++ c.setContributionsZero(n) ++ _, _, g1, g2 := curve.Generators() ++ ++ nextG1 := g1 ++ fillG1 := func(points []curve.G1Affine) { ++ for i := range points { ++ nextG1.Add(&nextG1, &g1) ++ points[i].Set(&nextG1) ++ } ++ } ++ fillG1(c.G1.Tau[1:]) ++ fillG1(c.G1.BetaTau) ++ fillG1(c.G1.AlphaTau) ++ ++ nextG2 := g2 ++ nextG2.Add(&nextG2, &g2) ++ c.G2.Beta.Set(&nextG2) ++ for i := 1; i < len(c.G2.Tau); i++ { ++ nextG2.Add(&nextG2, &g2) ++ c.G2.Tau[i].Set(&nextG2) ++ } ++ ++ if n > 1 && c.G1.Tau[0].Equal(&c.G1.Tau[1]) { ++ tb.Fatal("deterministic codec fixture contains repeated Tau points") ++ } ++ if n > 0 && c.G1.BetaTau[0].Equal(&c.G1.AlphaTau[0]) { ++ tb.Fatal("deterministic codec fixture contains repeated parameter points") ++ } ++ return c ++} ++ ++func TestParallelCommonsCodecMatchesSerial(t *testing.T) { ++ for _, n := range []uint64{1, 2, 17, srsCodecChunkSize + 1} { ++ c := codecTestCommons(t, n) ++ var want, got bytes.Buffer ++ wantN, err := writeCommonsSerial(&c, &want) ++ if err != nil { ++ t.Fatal(err) ++ } ++ gotN, err := c.WriteTo(&got) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if gotN != wantN || !bytes.Equal(got.Bytes(), want.Bytes()) { ++ t.Fatalf("parallel encoding differs from serial encoding for N=%d", n) ++ } ++ ++ var decoded SrsCommons ++ readN, err := decoded.ReadFrom(bytes.NewReader(got.Bytes())) ++ if err != nil { ++ t.Fatal(err) ++ } ++ if readN != gotN || !reflect.DeepEqual(decoded, c) { ++ t.Fatalf("parallel round trip differs for N=%d", n) ++ } ++ } ++} ++ ++func TestParallelCommonsDecoderRejectsMalformedPoint(t *testing.T) { ++ c := codecTestCommons(t, 2) ++ var encoded bytes.Buffer ++ if _, err := c.WriteTo(&encoded); err != nil { ++ t.Fatal(err) ++ } ++ data := encoded.Bytes() ++ firstG1 := 8 + curve.SizeOfG2AffineCompressed ++ data[firstG1] = 0xe0 // invalid point-encoding mask ++ var decoded SrsCommons ++ if _, err := decoded.ReadFrom(bytes.NewReader(data)); err == nil { ++ t.Fatal("malformed point was accepted") ++ } ++} ++ ++func TestParallelCommonsDecoderRejectsUncompressedPoints(t *testing.T) { ++ c := codecTestCommons(t, 2) ++ var encoded bytes.Buffer ++ enc := curve.NewEncoder(&encoded, curve.RawEncoding()) ++ if err := enc.Encode(uint64(len(c.G2.Tau))); err != nil { ++ t.Fatal(err) ++ } ++ values := []any{&c.G2.Beta} ++ for i := 1; i < len(c.G1.Tau); i++ { ++ values = append(values, &c.G1.Tau[i]) ++ } ++ for i := 1; i < len(c.G2.Tau); i++ { ++ values = append(values, &c.G2.Tau[i]) ++ } ++ for i := range c.G1.BetaTau { ++ values = append(values, &c.G1.BetaTau[i]) ++ } ++ for i := range c.G1.AlphaTau { ++ values = append(values, &c.G1.AlphaTau[i]) ++ } ++ for _, value := range values { ++ if err := enc.Encode(value); err != nil { ++ t.Fatal(err) ++ } ++ } ++ ++ var serial SrsCommons ++ if _, err := readCommonsSerial(&serial, bytes.NewReader(encoded.Bytes())); err != nil { ++ t.Fatalf("generic decoder rejected valid uncompressed fixture: %v", err) ++ } ++ var decoded SrsCommons ++ if _, err := decoded.ReadFrom(bytes.NewReader(encoded.Bytes())); err == nil { ++ t.Fatal("parallel compressed-only decoder accepted uncompressed Phase 1 points") ++ } ++} ++ ++func TestParallelCommonsDecoderCountsTruncatedInput(t *testing.T) { ++ var size [8]byte ++ binary.BigEndian.PutUint64(size[:], 1) ++ data := append(size[:], make([]byte, curve.SizeOfG2AffineCompressed-1)...) ++ var decoded SrsCommons ++ n, err := decoded.ReadFrom(bytes.NewReader(data)) ++ if err == nil { ++ t.Fatal("truncated input was accepted") ++ } ++ if n != int64(len(data)) { ++ t.Fatalf("read count %d, want %d", n, len(data)) ++ } ++} ++ ++func BenchmarkCommonsWriteSerial(b *testing.B) { ++ c := codecTestCommons(b, 1<<12) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ if _, err := writeCommonsSerial(&c, io.Discard); err != nil { ++ b.Fatal(err) ++ } ++ } ++} ++ ++func BenchmarkCommonsWriteParallel(b *testing.B) { ++ c := codecTestCommons(b, 1<<12) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ if _, err := c.WriteTo(io.Discard); err != nil { ++ b.Fatal(err) ++ } ++ } ++} ++ ++func benchmarkCommonsRead(b *testing.B, parallel bool) { ++ c := codecTestCommons(b, 1<<12) ++ var encoded bytes.Buffer ++ if _, err := writeCommonsSerial(&c, &encoded); err != nil { ++ b.Fatal(err) ++ } ++ data := encoded.Bytes() ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ var decoded SrsCommons ++ var err error ++ if parallel { ++ _, err = decoded.ReadFrom(bytes.NewReader(data)) ++ } else { ++ _, err = readCommonsSerial(&decoded, bytes.NewReader(data)) ++ } ++ if err != nil { ++ b.Fatal(err) ++ } ++ } ++} ++ ++func BenchmarkCommonsReadSerial(b *testing.B) { benchmarkCommonsRead(b, false) } ++func BenchmarkCommonsReadParallel(b *testing.B) { benchmarkCommonsRead(b, true) } diff --git a/experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch b/experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch new file mode 100644 index 0000000..96443cf --- /dev/null +++ b/experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch @@ -0,0 +1,194 @@ +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase1.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase1.go +@@ -16,6 +16,7 @@ + curve "github.com/consensys/gnark-crypto/ecc/bls12-381" + "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" + "github.com/consensys/gnark-crypto/ecc/bls12-381/mpcsetup" ++ "github.com/consensys/gnark/internal/utils" + ) + + // SrsCommons are the circuit-independent components of the Groth16 SRS, +@@ -102,9 +103,6 @@ + + // from the fourth argument on this just gives an opportunity to avoid recomputing some scalar multiplications + func (c *SrsCommons) update(tauUpdate, alphaUpdate, betaUpdate *fr.Element) { +- +- // TODO @gbotrel working with jacobian points here will help with perf. +- + // update α, β + var coeff big.Int + alphaUpdate.BigInt(&coeff) +@@ -113,35 +111,47 @@ + c.G1.BetaTau[0].ScalarMultiplication(&c.G1.BetaTau[0], &coeff) + c.G2.Beta.ScalarMultiplication(&c.G2.Beta, &coeff) + +- // update all values from 1 to N-1 +- tauPowI := *tauUpdate +- for i := 1; i < len(c.G2.Tau); i++ { +- tauPowI.BigInt(&coeff) +- +- c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) +- c.G2.Tau[i].ScalarMultiplication(&c.G2.Tau[i], &coeff) +- +- var tauPowIScaled fr.Element +- +- // let α₁ = α₀.α', τ₁ = τ₀.τ' +- // then α₁τ₁ⁱ = (α₀τ₀ⁱ)α'τ'ⁱ +- tauPowIScaled.Mul(&tauPowI, alphaUpdate) +- tauPowIScaled.BigInt(&coeff) +- c.G1.AlphaTau[i].ScalarMultiplication(&c.G1.AlphaTau[i], &coeff) +- +- // similarly for β +- tauPowIScaled.Mul(&tauPowI, betaUpdate) +- tauPowIScaled.BigInt(&coeff) +- c.G1.BetaTau[i].ScalarMultiplication(&c.G1.BetaTau[i], &coeff) ++ // Every worker owns a disjoint range and computes the first power it needs. ++ // Splitting the two ranges separately balances the more expensive first half ++ // (four scalar multiplications per index) independently from the second half. ++ utils.Parallelize(len(c.G2.Tau)-1, func(start, end int) { ++ start++ ++ end++ ++ updateTauRange(c, tauUpdate, alphaUpdate, betaUpdate, start, end, true) ++ }) ++ utils.Parallelize(len(c.G1.Tau)-len(c.G2.Tau), func(start, end int) { ++ start += len(c.G2.Tau) ++ end += len(c.G2.Tau) ++ updateTauRange(c, tauUpdate, alphaUpdate, betaUpdate, start, end, false) ++ }) ++} + +- tauPowI.Mul(&tauPowI, tauUpdate) +- } ++func updateTauRange(c *SrsCommons, tauUpdate, alphaUpdate, betaUpdate *fr.Element, start, end int, updateBothGroups bool) { ++ var exponent, coeff big.Int ++ exponent.SetUint64(uint64(start)) ++ var tauPowI fr.Element ++ tauPowI.Exp(*tauUpdate, &exponent) + +- // update the rest of [τⁱ]₁ +- for i := len(c.G2.Tau); i < len(c.G1.Tau); i++ { ++ for i := start; i < end; i++ { + tauPowI.BigInt(&coeff) + c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) + ++ if updateBothGroups { ++ c.G2.Tau[i].ScalarMultiplication(&c.G2.Tau[i], &coeff) ++ ++ var tauPowIScaled fr.Element ++ // let α₁ = α₀.α', τ₁ = τ₀.τ' ++ // then α₁τ₁ⁱ = (α₀τ₀ⁱ)α'τ'ⁱ ++ tauPowIScaled.Mul(&tauPowI, alphaUpdate) ++ tauPowIScaled.BigInt(&coeff) ++ c.G1.AlphaTau[i].ScalarMultiplication(&c.G1.AlphaTau[i], &coeff) ++ ++ // similarly for β ++ tauPowIScaled.Mul(&tauPowI, betaUpdate) ++ tauPowIScaled.BigInt(&coeff) ++ c.G1.BetaTau[i].ScalarMultiplication(&c.G1.BetaTau[i], &coeff) ++ } ++ + tauPowI.Mul(&tauPowI, tauUpdate) + } + } +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase1_parallel_test.go +@@ -0,0 +1,99 @@ ++package mpcsetup ++ ++import ( ++ "math/big" ++ "reflect" ++ "testing" ++ ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" ++) ++ ++func updateSerial(c *SrsCommons, tauUpdate, alphaUpdate, betaUpdate *fr.Element) { ++ var coeff big.Int ++ alphaUpdate.BigInt(&coeff) ++ c.G1.AlphaTau[0].ScalarMultiplication(&c.G1.AlphaTau[0], &coeff) ++ betaUpdate.BigInt(&coeff) ++ c.G1.BetaTau[0].ScalarMultiplication(&c.G1.BetaTau[0], &coeff) ++ c.G2.Beta.ScalarMultiplication(&c.G2.Beta, &coeff) ++ ++ tauPowI := *tauUpdate ++ for i := 1; i < len(c.G2.Tau); i++ { ++ tauPowI.BigInt(&coeff) ++ c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) ++ c.G2.Tau[i].ScalarMultiplication(&c.G2.Tau[i], &coeff) ++ ++ var scaled fr.Element ++ scaled.Mul(&tauPowI, alphaUpdate).BigInt(&coeff) ++ c.G1.AlphaTau[i].ScalarMultiplication(&c.G1.AlphaTau[i], &coeff) ++ scaled.Mul(&tauPowI, betaUpdate).BigInt(&coeff) ++ c.G1.BetaTau[i].ScalarMultiplication(&c.G1.BetaTau[i], &coeff) ++ tauPowI.Mul(&tauPowI, tauUpdate) ++ } ++ for i := len(c.G2.Tau); i < len(c.G1.Tau); i++ { ++ tauPowI.BigInt(&coeff) ++ c.G1.Tau[i].ScalarMultiplication(&c.G1.Tau[i], &coeff) ++ tauPowI.Mul(&tauPowI, tauUpdate) ++ } ++} ++ ++func testCommons(n uint64) SrsCommons { ++ var c SrsCommons ++ c.setOne(n) ++ return c ++} ++ ++func TestParallelUpdateMatchesSerial(t *testing.T) { ++ var tau, alpha, beta fr.Element ++ tau.SetUint64(17) ++ alpha.SetUint64(23) ++ beta.SetUint64(29) ++ ++ for _, n := range []uint64{1, 2, 3, 17, 64} { ++ t.Run(new(big.Int).SetUint64(n).String(), func(t *testing.T) { ++ want := testCommons(n) ++ got := testCommons(n) ++ updateSerial(&want, &tau, &alpha, &beta) ++ got.update(&tau, &alpha, &beta) ++ if !reflect.DeepEqual(got, want) { ++ t.Fatal("parallel update differs from the serial implementation") ++ } ++ }) ++ } ++} ++ ++func benchmarkUpdate(b *testing.B, parallel bool) { ++ const n = 1 << 10 ++ var tau, alpha, beta fr.Element ++ tau.SetUint64(17) ++ alpha.SetUint64(23) ++ beta.SetUint64(29) ++ base := testCommons(n) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ c := cloneCommons(base) ++ if parallel { ++ c.update(&tau, &alpha, &beta) ++ } else { ++ updateSerial(&c, &tau, &alpha, &beta) ++ } ++ } ++} ++ ++func cloneCommons(c SrsCommons) SrsCommons { ++ cloneG1 := func(points []curve.G1Affine) []curve.G1Affine { ++ return append([]curve.G1Affine(nil), points...) ++ } ++ cloneG2 := func(points []curve.G2Affine) []curve.G2Affine { ++ return append([]curve.G2Affine(nil), points...) ++ } ++ c.G1.Tau = cloneG1(c.G1.Tau) ++ c.G1.AlphaTau = cloneG1(c.G1.AlphaTau) ++ c.G1.BetaTau = cloneG1(c.G1.BetaTau) ++ c.G2.Tau = cloneG2(c.G2.Tau) ++ return c ++} ++ ++func BenchmarkUpdateSerial(b *testing.B) { benchmarkUpdate(b, false) } ++func BenchmarkUpdateParallel(b *testing.B) { benchmarkUpdate(b, true) } diff --git a/experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch b/experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch new file mode 100644 index 0000000..1d3a82c --- /dev/null +++ b/experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch @@ -0,0 +1,640 @@ +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2.go +@@ -11,6 +11,7 @@ + "errors" + "fmt" + "math/big" ++ "runtime" + "slices" + + curve "github.com/consensys/gnark-crypto/ecc/bls12-381" +@@ -156,12 +157,30 @@ + // It involves no coin tosses. A verifier should + // simply rerun all the steps + func (p *Phase2) Initialize(r1cs *cs.R1CS, commons *SrsCommons) Phase2Evaluations { ++ return p.initialize(r1cs, commons, runtime.NumCPU()) ++} ++ ++const phase2ConstraintBatchSize = 16 * 1024 ++ ++type phase2WeightedTerm struct { ++ constraintIndex int ++ term constraint.Term ++} ++ ++type phase2WorkerTerms struct { ++ left, right, output []phase2WeightedTerm ++} ++ ++func (p *Phase2) initialize(r1cs *cs.R1CS, commons *SrsCommons, nbTasks int) Phase2Evaluations { + // TODO @Tabaie option to only compute the phase 2 info and not the evaluations, for a contributor + + n := len(commons.G1.AlphaTau) + if n < r1cs.GetNbConstraints() { + panic("Number of constraints is larger than expected") + } ++ if nbTasks < 1 { ++ nbTasks = 1 ++ } + + accumulateG1 := func(res *curve.G1Affine, t constraint.Term, value *curve.G1Affine) { + cID := t.CoeffID() +@@ -204,10 +223,10 @@ + } + + // Prepare Lagrange coefficients of [τ...]₁, [τ...]₂, [ατ...]₁, [βτ...]₁ +- coeffTau1 := lagrangeCoeffsG1(commons.G1.Tau, n) // [L_{ω⁰}(τ)]₁, [L_{ω¹}(τ)]₁, ... where ω is a primitive sizeᵗʰ root of unity +- coeffTau2 := lagrangeCoeffsG2(commons.G2.Tau, n) // [L_{ω⁰}(τ)]₂, [L_{ω¹}(τ)]₂, ... +- coeffAlphaTau1 := lagrangeCoeffsG1(commons.G1.AlphaTau, n) // [L_{ω⁰}(ατ)]₁, [L_{ω¹}(ατ)]₁, ... +- coeffBetaTau1 := lagrangeCoeffsG1(commons.G1.BetaTau, n) // [L_{ω⁰}(βτ)]₁, [L_{ω¹}(βτ)]₁, ... ++ coeffTau1 := lagrangeCoeffsG1WithTasks(commons.G1.Tau, n, nbTasks) // [L_{ω⁰}(τ)]₁, [L_{ω¹}(τ)]₁, ... where ω is a primitive sizeᵗʰ root of unity ++ coeffTau2 := lagrangeCoeffsG2WithTasks(commons.G2.Tau, n, nbTasks) // [L_{ω⁰}(τ)]₂, [L_{ω¹}(τ)]₂, ... ++ coeffAlphaTau1 := lagrangeCoeffsG1WithTasks(commons.G1.AlphaTau, n, nbTasks) // [L_{ω⁰}(ατ)]₁, [L_{ω¹}(ατ)]₁, ... ++ coeffBetaTau1 := lagrangeCoeffsG1WithTasks(commons.G1.BetaTau, n, nbTasks) // [L_{ω⁰}(βτ)]₁, [L_{ω¹}(βτ)]₁, ... + + nbInternal, nbSecret, nbPublic := r1cs.GetNbVariables() + nWires := nbInternal + nbSecret + nbPublic +@@ -221,30 +240,57 @@ + aB := make([]curve.G1Affine, nWires) + C := make([]curve.G1Affine, nWires) + ++ nbTasks = min(nbTasks, max(nWires, 1)) ++ workerTerms := make([]phase2WorkerTerms, nbTasks) ++ flushTerms := func() { ++ utils.Parallelize(nbTasks, func(start, end int) { ++ for worker := start; worker < end; worker++ { ++ for _, weighted := range workerTerms[worker].left { ++ t := weighted.term ++ wireID := t.WireID() ++ accumulateG1(&evals.G1.A[wireID], t, &coeffTau1[weighted.constraintIndex]) ++ accumulateG1(&bA[wireID], t, &coeffBetaTau1[weighted.constraintIndex]) ++ } ++ for _, weighted := range workerTerms[worker].right { ++ t := weighted.term ++ wireID := t.WireID() ++ accumulateG1(&evals.G1.B[wireID], t, &coeffTau1[weighted.constraintIndex]) ++ accumulateG2(&evals.G2.B[wireID], t, &coeffTau2[weighted.constraintIndex]) ++ accumulateG1(&aB[wireID], t, &coeffAlphaTau1[weighted.constraintIndex]) ++ } ++ for _, weighted := range workerTerms[worker].output { ++ t := weighted.term ++ wireID := t.WireID() ++ accumulateG1(&C[wireID], t, &coeffTau1[weighted.constraintIndex]) ++ } ++ workerTerms[worker].left = workerTerms[worker].left[:0] ++ workerTerms[worker].right = workerTerms[worker].right[:0] ++ workerTerms[worker].output = workerTerms[worker].output[:0] ++ } ++ }, nbTasks) ++ } ++ + i := 0 + it := r1cs.GetR1CIterator() + for c := it.Next(); c != nil; c = it.Next() { +- // each constraint is sparse, i.e. involves a small portion of all variables. +- // so we iterate over the variables involved and add the constraint's contribution +- // to every variable's A, B, and C values +- +- // A + for _, t := range c.L { +- accumulateG1(&evals.G1.A[t.WireID()], t, &coeffTau1[i]) +- accumulateG1(&bA[t.WireID()], t, &coeffBetaTau1[i]) ++ worker := t.WireID() % nbTasks ++ workerTerms[worker].left = append(workerTerms[worker].left, phase2WeightedTerm{i, t}) + } +- // B + for _, t := range c.R { +- accumulateG1(&evals.G1.B[t.WireID()], t, &coeffTau1[i]) +- accumulateG2(&evals.G2.B[t.WireID()], t, &coeffTau2[i]) +- accumulateG1(&aB[t.WireID()], t, &coeffAlphaTau1[i]) ++ worker := t.WireID() % nbTasks ++ workerTerms[worker].right = append(workerTerms[worker].right, phase2WeightedTerm{i, t}) + } +- // C + for _, t := range c.O { +- accumulateG1(&C[t.WireID()], t, &coeffTau1[i]) ++ worker := t.WireID() % nbTasks ++ workerTerms[worker].output = append(workerTerms[worker].output, phase2WeightedTerm{i, t}) + } + i++ ++ if i%phase2ConstraintBatchSize == 0 { ++ flushTerms() ++ } + } ++ flushTerms() + + // Prepare default contribution + _, _, g1, g2 := curve.Generators() +@@ -254,9 +300,11 @@ + // Build Z in PK as τⁱ(τⁿ - 1) = τ⁽ⁱ⁺ⁿ⁾ - τⁱ for i ∈ [0, n-2] + // τⁱ(τⁿ - 1) = τ⁽ⁱ⁺ⁿ⁾ - τⁱ for i ∈ [0, n-2] + p.Parameters.G1.Z = make([]curve.G1Affine, n) +- for i := range n - 1 { +- p.Parameters.G1.Z[i].Sub(&commons.G1.Tau[i+n], &commons.G1.Tau[i]) +- } ++ utils.Parallelize(n-1, func(start, end int) { ++ for i := start; i < end; i++ { ++ p.Parameters.G1.Z[i].Sub(&commons.G1.Tau[i+n], &commons.G1.Tau[i]) ++ } ++ }, nbTasks) + bitReverse(p.Parameters.G1.Z) + p.Parameters.G1.Z = p.Parameters.G1.Z[:n-1] + +@@ -280,11 +328,16 @@ + evals.G1.VKK = make([]curve.G1Affine, 0, nbPublic+len(commitments)) + committedIterator := internal.NewMergeIterator(commitments.GetPrivateCommitted()) + nbCommitmentsSeen := 0 ++ combined := make([]curve.G1Affine, nWires) ++ utils.Parallelize(nWires, func(start, end int) { ++ for j := start; j < end; j++ { ++ combined[j].Add(&bA[j], &aB[j]) ++ combined[j].Add(&combined[j], &C[j]) ++ } ++ }, nbTasks) + for j := 0; j < nWires; j++ { + // since as yet δ, γ = 1, the VKK and PKK are computed identically, as βA + αB + C +- var tmp curve.G1Affine +- tmp.Add(&bA[j], &aB[j]) +- tmp.Add(&tmp, &C[j]) ++ tmp := combined[j] + commitmentIndex := committedIterator.IndexIfNext(j) + isCommitment := nbCommitmentsSeen < len(commitments) && commitments[nbCommitmentsSeen].CommitmentIndex == j + if commitmentIndex != -1 { +--- vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/lagrange.go ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/lagrange.go +@@ -19,10 +19,14 @@ + + // TODO use gnark-crypto for this op + func lagrangeCoeffsG1(powers []curve.G1Affine, size int) []curve.G1Affine { ++ return lagrangeCoeffsG1WithTasks(powers, size, runtime.NumCPU()) ++} ++ ++func lagrangeCoeffsG1WithTasks(powers []curve.G1Affine, size, nbTasks int) []curve.G1Affine { + coeffs := make([]curve.G1Affine, size) + copy(coeffs, powers[:size]) + domain := fft.NewDomain(uint64(size)) +- numCPU := uint64(runtime.NumCPU()) ++ numCPU := uint64(max(nbTasks, 1)) + maxSplits := bits.TrailingZeros64(ecc.NextPowerOfTwo(numCPU)) + + twiddlesInv, _ := domain.TwiddlesInv() +@@ -36,16 +40,20 @@ + for i := start; i < end; i++ { + coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) + } +- }) ++ }, nbTasks) + return coeffs + } + + // TODO use gnark-crypto for this op + func lagrangeCoeffsG2(powers []curve.G2Affine, size int) []curve.G2Affine { ++ return lagrangeCoeffsG2WithTasks(powers, size, runtime.NumCPU()) ++} ++ ++func lagrangeCoeffsG2WithTasks(powers []curve.G2Affine, size, nbTasks int) []curve.G2Affine { + coeffs := make([]curve.G2Affine, size) + copy(coeffs, powers[:size]) + domain := fft.NewDomain(uint64(size)) +- numCPU := uint64(runtime.NumCPU()) ++ numCPU := uint64(max(nbTasks, 1)) + maxSplits := bits.TrailingZeros64(ecc.NextPowerOfTwo(numCPU)) + + twiddlesInv, _ := domain.TwiddlesInv() +@@ -59,7 +67,7 @@ + for i := start; i < end; i++ { + coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) + } +- }) ++ }, nbTasks) + return coeffs + } + +@@ -145,12 +153,18 @@ + + butterflyG1(&a[0], &a[m]) + +- var twiddle big.Int +- for i := 1; i < m; i++ { +- butterflyG1(&a[i], &a[i+m]) +- twiddles[stage][i].BigInt(&twiddle) +- a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ stageTasks := 1 ++ if stage < maxSplits { ++ stageTasks = 1 << (maxSplits - stage) + } ++ utils.Parallelize(m-1, func(start, end int) { ++ var twiddle big.Int ++ for i := start + 1; i < end+1; i++ { ++ butterflyG1(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ }, stageTasks) + + if m == 1 { + return +@@ -183,12 +197,18 @@ + + butterflyG2(&a[0], &a[m]) + +- var twiddle big.Int +- for i := 1; i < m; i++ { +- butterflyG2(&a[i], &a[i+m]) +- twiddles[stage][i].BigInt(&twiddle) +- a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ stageTasks := 1 ++ if stage < maxSplits { ++ stageTasks = 1 << (maxSplits - stage) + } ++ utils.Parallelize(m-1, func(start, end int) { ++ var twiddle big.Int ++ for i := start + 1; i < end+1; i++ { ++ butterflyG2(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ }, stageTasks) + + if m == 1 { + return +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2_parallel_test.go +@@ -0,0 +1,137 @@ ++package mpcsetup ++ ++import ( ++ "fmt" ++ "math/big" ++ "reflect" ++ "runtime" ++ "testing" ++ ++ "github.com/consensys/gnark-crypto/ecc" ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" ++ cs "github.com/consensys/gnark/constraint/bls12-381" ++ "github.com/consensys/gnark/frontend" ++ "github.com/consensys/gnark/frontend/cs/r1cs" ++) ++ ++type phase2ParallelCircuit struct { ++ X frontend.Variable ++ Y frontend.Variable `gnark:",public"` ++ iterations int ++} ++ ++func (c *phase2ParallelCircuit) Define(api frontend.API) error { ++ value := c.X ++ for i := 0; i < c.iterations; i++ { ++ value = api.Mul(value, c.X) ++ value = api.Add(value, i+1) ++ } ++ api.AssertIsEqual(value, c.Y) ++ return nil ++} ++ ++func phase2DistinctCommons(tb testing.TB, n uint64) *SrsCommons { ++ tb.Helper() ++ var commons SrsCommons ++ commons.setContributionsZero(n) ++ _, _, g1, g2 := curve.Generators() ++ ++ var one, tau, alpha, beta fr.Element ++ one.SetOne() ++ tau.SetUint64(5) ++ alpha.SetUint64(7) ++ beta.SetUint64(11) ++ ++ fillG1Powers := func(points []curve.G1Affine, scale *fr.Element) { ++ power := one ++ var scalar fr.Element ++ var scalarBig big.Int ++ for i := range points { ++ scalar.Mul(&power, scale) ++ scalar.BigInt(&scalarBig) ++ points[i].ScalarMultiplication(&g1, &scalarBig) ++ power.Mul(&power, &tau) ++ } ++ } ++ fillG2Powers := func(points []curve.G2Affine) { ++ power := one ++ var scalarBig big.Int ++ for i := range points { ++ power.BigInt(&scalarBig) ++ points[i].ScalarMultiplication(&g2, &scalarBig) ++ power.Mul(&power, &tau) ++ } ++ } ++ ++ fillG1Powers(commons.G1.Tau, &one) ++ fillG1Powers(commons.G1.AlphaTau, &alpha) ++ fillG1Powers(commons.G1.BetaTau, &beta) ++ fillG2Powers(commons.G2.Tau) ++ var betaBig big.Int ++ beta.BigInt(&betaBig) ++ commons.G2.Beta.ScalarMultiplication(&g2, &betaBig) ++ ++ if n > 1 && (commons.G1.Tau[0].Equal(&commons.G1.Tau[1]) || commons.G2.Tau[0].Equal(&commons.G2.Tau[1])) { ++ tb.Fatal("deterministic SRS fixture contains repeated powers") ++ } ++ return &commons ++} ++ ++func phase2ParallelFixture(tb testing.TB, iterations int) (*cs.R1CS, *SrsCommons) { ++ tb.Helper() ++ compiled, err := frontend.Compile(curve.ID.ScalarField(), r1cs.NewBuilder, &phase2ParallelCircuit{iterations: iterations}) ++ if err != nil { ++ tb.Fatal(err) ++ } ++ constraints := compiled.(*cs.R1CS) ++ domainSize := ecc.NextPowerOfTwo(uint64(constraints.GetNbConstraints())) ++ return constraints, phase2DistinctCommons(tb, domainSize) ++} ++ ++func TestParallelPhase2InitializeMatchesUpstreamSerialReference(t *testing.T) { ++ constraints, commons := phase2ParallelFixture(t, 64) ++ lagrange := phase2SerialLagrangeG1(commons.G1.Tau, len(commons.G1.AlphaTau)) ++ nonInfinity := 0 ++ for i := range lagrange { ++ if !lagrange[i].IsInfinity() { ++ nonInfinity++ ++ } ++ } ++ if nonInfinity < len(lagrange)/2 { ++ t.Fatalf("deterministic SRS fixture collapsed to %d/%d non-infinity Lagrange coefficients", nonInfinity, len(lagrange)) ++ } ++ ++ var wantPhase2 Phase2 ++ wantEvals := initializePhase2SerialReference(&wantPhase2, constraints, commons) ++ for _, nbTasks := range []int{1, 4} { ++ t.Run(fmt.Sprintf("%d-workers", nbTasks), func(t *testing.T) { ++ var gotPhase2 Phase2 ++ gotEvals := gotPhase2.initialize(constraints, commons, nbTasks) ++ if !reflect.DeepEqual(gotEvals, wantEvals) { ++ t.Fatalf("%d-worker Phase 2 evaluations differ from upstream serial reference", nbTasks) ++ } ++ if !reflect.DeepEqual(gotPhase2, wantPhase2) { ++ t.Fatalf("%d-worker Phase 2 parameters differ from upstream serial reference", nbTasks) ++ } ++ }) ++ } ++} ++ ++func benchmarkPhase2Initialize(b *testing.B, nbTasks int) { ++ constraints, commons := phase2ParallelFixture(b, 4096) ++ b.ReportAllocs() ++ b.ResetTimer() ++ for i := 0; i < b.N; i++ { ++ var phase2 Phase2 ++ phase2.initialize(constraints, commons, nbTasks) ++ } ++} ++ ++func BenchmarkPhase2InitializeSingleWorker(b *testing.B) { ++ benchmarkPhase2Initialize(b, 1) ++} ++ ++func BenchmarkPhase2InitializeParallel(b *testing.B) { ++ benchmarkPhase2Initialize(b, runtime.NumCPU()) ++} +--- /dev/null ++++ vendor/github.com/consensys/gnark/backend/groth16/bls12-381/mpcsetup/phase2_serial_reference_test.go +@@ -0,0 +1,237 @@ ++package mpcsetup ++ ++import ( ++ "math/big" ++ "slices" ++ ++ curve "github.com/consensys/gnark-crypto/ecc/bls12-381" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr" ++ "github.com/consensys/gnark-crypto/ecc/bls12-381/fr/fft" ++ cryptompcsetup "github.com/consensys/gnark-crypto/ecc/bls12-381/mpcsetup" ++ "github.com/consensys/gnark/backend/groth16/internal" ++ "github.com/consensys/gnark/constraint" ++ cs "github.com/consensys/gnark/constraint/bls12-381" ++) ++ ++// These helpers retain the upstream gnark v0.15.0 single-threaded algorithms ++// as an independent oracle for the parallel Phase 2 patch. ++func phase2SerialLagrangeG1(powers []curve.G1Affine, size int) []curve.G1Affine { ++ coeffs := make([]curve.G1Affine, size) ++ copy(coeffs, powers[:size]) ++ domain := fft.NewDomain(uint64(size)) ++ twiddlesInv, _ := domain.TwiddlesInv() ++ phase2SerialDIFG1(coeffs, twiddlesInv, 0) ++ bitReverse(coeffs) ++ ++ var invBigint big.Int ++ domain.CardinalityInv.BigInt(&invBigint) ++ for i := range coeffs { ++ coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) ++ } ++ return coeffs ++} ++ ++func phase2SerialLagrangeG2(powers []curve.G2Affine, size int) []curve.G2Affine { ++ coeffs := make([]curve.G2Affine, size) ++ copy(coeffs, powers[:size]) ++ domain := fft.NewDomain(uint64(size)) ++ twiddlesInv, _ := domain.TwiddlesInv() ++ phase2SerialDIFG2(coeffs, twiddlesInv, 0) ++ bitReverse(coeffs) ++ ++ var invBigint big.Int ++ domain.CardinalityInv.BigInt(&invBigint) ++ for i := range coeffs { ++ coeffs[i].ScalarMultiplication(&coeffs[i], &invBigint) ++ } ++ return coeffs ++} ++ ++func phase2SerialDIFG1(a []curve.G1Affine, twiddles [][]fr.Element, stage int) { ++ n := len(a) ++ if n == 1 { ++ return ++ } ++ if n == 8 { ++ kerDIF8G1(a, twiddles, stage) ++ return ++ } ++ m := n >> 1 ++ phase2SerialButterflyG1(&a[0], &a[m]) ++ var twiddle big.Int ++ for i := 1; i < m; i++ { ++ phase2SerialButterflyG1(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ if m == 1 { ++ return ++ } ++ phase2SerialDIFG1(a[:m], twiddles, stage+1) ++ phase2SerialDIFG1(a[m:], twiddles, stage+1) ++} ++ ++func phase2SerialDIFG2(a []curve.G2Affine, twiddles [][]fr.Element, stage int) { ++ n := len(a) ++ if n == 1 { ++ return ++ } ++ if n == 8 { ++ kerDIF8G2(a, twiddles, stage) ++ return ++ } ++ m := n >> 1 ++ phase2SerialButterflyG2(&a[0], &a[m]) ++ var twiddle big.Int ++ for i := 1; i < m; i++ { ++ phase2SerialButterflyG2(&a[i], &a[i+m]) ++ twiddles[stage][i].BigInt(&twiddle) ++ a[i+m].ScalarMultiplication(&a[i+m], &twiddle) ++ } ++ if m == 1 { ++ return ++ } ++ phase2SerialDIFG2(a[:m], twiddles, stage+1) ++ phase2SerialDIFG2(a[m:], twiddles, stage+1) ++} ++ ++func phase2SerialButterflyG1(a, b *curve.G1Affine) { ++ t := *a ++ a.Add(a, b) ++ b.Sub(&t, b) ++} ++ ++func phase2SerialButterflyG2(a, b *curve.G2Affine) { ++ t := *a ++ a.Add(a, b) ++ b.Sub(&t, b) ++} ++ ++func initializePhase2SerialReference(p *Phase2, r1cs *cs.R1CS, commons *SrsCommons) Phase2Evaluations { ++ n := len(commons.G1.AlphaTau) ++ if n < r1cs.GetNbConstraints() { ++ panic("Number of constraints is larger than expected") ++ } ++ ++ accumulateG1 := func(res *curve.G1Affine, term constraint.Term, value *curve.G1Affine) { ++ cID := term.CoeffID() ++ switch cID { ++ case constraint.CoeffIdZero: ++ return ++ case constraint.CoeffIdOne: ++ res.Add(res, value) ++ case constraint.CoeffIdMinusOne: ++ res.Sub(res, value) ++ case constraint.CoeffIdTwo: ++ res.Add(res, value).Add(res, value) ++ default: ++ var tmp curve.G1Affine ++ var coefficient big.Int ++ r1cs.Coefficients[cID].BigInt(&coefficient) ++ tmp.ScalarMultiplication(value, &coefficient) ++ res.Add(res, &tmp) ++ } ++ } ++ accumulateG2 := func(res *curve.G2Affine, term constraint.Term, value *curve.G2Affine) { ++ cID := term.CoeffID() ++ switch cID { ++ case constraint.CoeffIdZero: ++ return ++ case constraint.CoeffIdOne: ++ res.Add(res, value) ++ case constraint.CoeffIdMinusOne: ++ res.Sub(res, value) ++ case constraint.CoeffIdTwo: ++ res.Add(res, value).Add(res, value) ++ default: ++ var tmp curve.G2Affine ++ var coefficient big.Int ++ r1cs.Coefficients[cID].BigInt(&coefficient) ++ tmp.ScalarMultiplication(value, &coefficient) ++ res.Add(res, &tmp) ++ } ++ } ++ ++ coeffTau1 := phase2SerialLagrangeG1(commons.G1.Tau, n) ++ coeffTau2 := phase2SerialLagrangeG2(commons.G2.Tau, n) ++ coeffAlphaTau1 := phase2SerialLagrangeG1(commons.G1.AlphaTau, n) ++ coeffBetaTau1 := phase2SerialLagrangeG1(commons.G1.BetaTau, n) ++ ++ nbInternal, nbSecret, nbPublic := r1cs.GetNbVariables() ++ nWires := nbInternal + nbSecret + nbPublic ++ var evals Phase2Evaluations ++ commitmentInfo := r1cs.CommitmentInfo.(constraint.Groth16Commitments) ++ evals.PublicAndCommitmentCommitted = commitmentInfo.GetPublicAndCommitmentCommitted(commitmentInfo.CommitmentIndexes(), nbPublic) ++ evals.G1.A = make([]curve.G1Affine, nWires) ++ evals.G1.B = make([]curve.G1Affine, nWires) ++ evals.G2.B = make([]curve.G2Affine, nWires) ++ bA := make([]curve.G1Affine, nWires) ++ aB := make([]curve.G1Affine, nWires) ++ cValues := make([]curve.G1Affine, nWires) ++ ++ i := 0 ++ it := r1cs.GetR1CIterator() ++ for c := it.Next(); c != nil; c = it.Next() { ++ for _, term := range c.L { ++ accumulateG1(&evals.G1.A[term.WireID()], term, &coeffTau1[i]) ++ accumulateG1(&bA[term.WireID()], term, &coeffBetaTau1[i]) ++ } ++ for _, term := range c.R { ++ accumulateG1(&evals.G1.B[term.WireID()], term, &coeffTau1[i]) ++ accumulateG2(&evals.G2.B[term.WireID()], term, &coeffTau2[i]) ++ accumulateG1(&aB[term.WireID()], term, &coeffAlphaTau1[i]) ++ } ++ for _, term := range c.O { ++ accumulateG1(&cValues[term.WireID()], term, &coeffTau1[i]) ++ } ++ i++ ++ } ++ ++ _, _, g1, g2 := curve.Generators() ++ p.Parameters.G1.Delta = g1 ++ p.Parameters.G2.Delta = g2 ++ p.Parameters.G1.Z = make([]curve.G1Affine, n) ++ for i := 0; i < n-1; i++ { ++ p.Parameters.G1.Z[i].Sub(&commons.G1.Tau[i+n], &commons.G1.Tau[i]) ++ } ++ bitReverse(p.Parameters.G1.Z) ++ p.Parameters.G1.Z = p.Parameters.G1.Z[:n-1] ++ ++ commitments := r1cs.CommitmentInfo.(constraint.Groth16Commitments) ++ evals.G1.CKK = make([][]curve.G1Affine, len(commitments)) ++ p.Sigmas = make([]cryptompcsetup.UpdateProof, len(commitments)) ++ p.Parameters.G1.SigmaCKK = make([][]curve.G1Affine, len(commitments)) ++ p.Parameters.G2.Sigma = make([]curve.G2Affine, len(commitments)) ++ for j := range commitments { ++ evals.G1.CKK[j] = make([]curve.G1Affine, 0, len(commitments[j].PrivateCommitted)) ++ p.Parameters.G2.Sigma[j] = g2 ++ } ++ ++ nbCommitted := internal.NbElements(commitments.GetPrivateCommitted()) ++ p.Parameters.G1.PKK = make([]curve.G1Affine, 0, nbInternal+nbSecret-nbCommitted-len(commitments)) ++ evals.G1.VKK = make([]curve.G1Affine, 0, nbPublic+len(commitments)) ++ committedIterator := internal.NewMergeIterator(commitments.GetPrivateCommitted()) ++ nbCommitmentsSeen := 0 ++ for j := 0; j < nWires; j++ { ++ var tmp curve.G1Affine ++ tmp.Add(&bA[j], &aB[j]) ++ tmp.Add(&tmp, &cValues[j]) ++ commitmentIndex := committedIterator.IndexIfNext(j) ++ isCommitment := nbCommitmentsSeen < len(commitments) && commitments[nbCommitmentsSeen].CommitmentIndex == j ++ if commitmentIndex != -1 { ++ evals.G1.CKK[commitmentIndex] = append(evals.G1.CKK[commitmentIndex], tmp) ++ } else if j < nbPublic || isCommitment { ++ evals.G1.VKK = append(evals.G1.VKK, tmp) ++ } else { ++ p.Parameters.G1.PKK = append(p.Parameters.G1.PKK, tmp) ++ } ++ if isCommitment { ++ nbCommitmentsSeen++ ++ } ++ } ++ for j := range commitments { ++ p.Parameters.G1.SigmaCKK[j] = slices.Clone(evals.G1.CKK[j]) ++ } ++ p.Challenge = nil ++ return evals ++} diff --git a/go.mod b/go.mod index a341d7f..629e64c 100644 --- a/go.mod +++ b/go.mod @@ -1,6 +1,6 @@ module proof-tool -go 1.26.5 +go 1.26.6 require ( filippo.io/edwards25519 v1.2.0 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/adversarial_test.go b/internal/mpcceremony/adversarial_test.go index 72a2f71..b1c353a 100644 --- a/internal/mpcceremony/adversarial_test.go +++ b/internal/mpcceremony/adversarial_test.go @@ -206,16 +206,23 @@ func adversarialDefinition(t *testing.T) CeremonyDefinition { CreatedAt: "2026-07-23T12:00:00Z", SessionNonceHex: strings.Repeat("5a", 32), Circuit: CircuitBinding{ - KeyVersion: KeyVersionDestinationV2, - CircuitID: CircuitIDDestinationV2, - Curve: CurveBLS12381, - Backend: BackendGroth16, - R1CS: ArtifactRef{Name: "ownership-destination.ccs", Digest: NewDigest([]byte("r1cs"))}, - Constraints: 7, + KeyVersion: KeyVersionDestinationV2, + CircuitID: CircuitIDDestinationV2, + Curve: CurveBLS12381, + Backend: BackendGroth16, + R1CS: ArtifactRef{ + Name: "ownership-destination.ccs", + Digest: Digest{ + SHA256: CanonicalDestinationV2SHA256, + Blake2b256: CanonicalDestinationV2Blake2b256, + Size: CanonicalDestinationV2Size, + }, + }, + Constraints: CanonicalDestinationV2Constraints, InternalVariables: 3, SecretVariables: 2, PublicVariables: 1, - DomainSize: 8, + DomainSize: 1 << 21, Phase2Shape: Phase2Shape{ Commitments: 1, PKK: 1, @@ -419,7 +426,7 @@ func TestCeremonyDefinitionRejectsMetadataDrift(t *testing.T) { }}, {name: "dirty source", mutate: func(d *CeremonyDefinition) { d.Software.SourceDirty = true }}, {name: "wrong Go version", mutate: func(d *CeremonyDefinition) { - d.Software.GoVersion = "go1.26.6" + d.Software.GoVersion = "go1.26.5" }}, {name: "wrong target OS", mutate: func(d *CeremonyDefinition) { d.Software.GoOS = "darwin" diff --git a/internal/mpcceremony/attestation.go b/internal/mpcceremony/attestation.go index f153380..2725242 100644 --- a/internal/mpcceremony/attestation.go +++ b/internal/mpcceremony/attestation.go @@ -6,6 +6,7 @@ import ( "encoding/hex" "errors" "fmt" + "strings" "time" ) @@ -142,8 +143,9 @@ type ContributionEnvironment struct { } func (e ContributionEnvironment) Validate() error { - if e.OS == "" || e.Architecture == "" { - return errors.New("contribution environment OS and architecture are required") + if strings.TrimSpace(e.OS) == "" || e.OS != strings.TrimSpace(e.OS) || + strings.TrimSpace(e.Architecture) == "" || e.Architecture != strings.TrimSpace(e.Architecture) { + return errors.New("contribution environment OS and architecture must be non-empty and trimmed") } if e.EntropySource != "operating-system-csprng" { return fmt.Errorf("entropy_source %q, want operating-system-csprng", e.EntropySource) diff --git a/internal/mpcceremony/audit.go b/internal/mpcceremony/audit.go index 7d98276..aceb6a8 100644 --- a/internal/mpcceremony/audit.go +++ b/internal/mpcceremony/audit.go @@ -1318,11 +1318,21 @@ func copyOperationalEvidence( return nil } +// bundleAuditArtifacts copies the audit reports into the staging tree in +// ascending auditor-ID order. The bundled order is frozen into the final +// transcript, and the production decision independently requires its audits +// ascending by auditor ID and then requires the transcript refs to match that +// order exactly — so bundling in the caller's flag order would sign a release +// for which no valid decision can ever exist. func bundleAuditArtifacts(inputs []AuditArtifact, stagingDir string) ([]AuditArtifact, error) { auditDir := filepath.Join(stagingDir, "audits") if err := os.Mkdir(auditDir, 0o700); err != nil { return nil, err } + inputs, err := sortAuditArtifactsByAuditorID(inputs) + if err != nil { + return nil, err + } result := make([]AuditArtifact, len(inputs)) for index, input := range inputs { logicalRecord := fmt.Sprintf("audits/%04d.json", index+1) @@ -1344,6 +1354,36 @@ func bundleAuditArtifacts(inputs []AuditArtifact, stagingDir string) ([]AuditArt return result, nil } +// sortAuditArtifactsByAuditorID orders the supplied reports by the auditor ID +// each record names. The records are only read here; authentication and +// enrollment checks run in verifyPassingAudits on the bundled copies. +func sortAuditArtifactsByAuditorID(inputs []AuditArtifact) ([]AuditArtifact, error) { + type keyed struct { + artifact AuditArtifact + auditorID string + } + entries := make([]keyed, len(inputs)) + for index, input := range inputs { + recordBytes, err := readRegularFile(input.RecordPath) + if err != nil { + return nil, fmt.Errorf("audit %d: %w", index, err) + } + var record AuditRecord + if err := UnmarshalCanonical(recordBytes, &record); err != nil { + return nil, fmt.Errorf("audit %d: %w", index, err) + } + entries[index] = keyed{artifact: input, auditorID: record.AuditorID} + } + slices.SortStableFunc(entries, func(a, b keyed) int { + return strings.Compare(a.auditorID, b.auditorID) + }) + sorted := make([]AuditArtifact, len(entries)) + for index, entry := range entries { + sorted[index] = entry.artifact + } + return sorted, nil +} + func bundledAuditsForTranscript(keysDir string, refs []ArtifactRef) ([]AuditArtifact, error) { result := make([]AuditArtifact, len(refs)) for index, ref := range refs { diff --git a/internal/mpcceremony/beacon_round_derivation_test.go b/internal/mpcceremony/beacon_round_derivation_test.go new file mode 100644 index 0000000..07741b5 --- /dev/null +++ b/internal/mpcceremony/beacon_round_derivation_test.go @@ -0,0 +1,128 @@ +package mpcceremony + +import ( + "testing" + "time" +) + +func TestFirstQuicknetRoundAfterIsStrictlyAfter(t *testing.T) { + for _, round := range []uint64{1, 2, 1000, 31345533} { + scheduled, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + // Landing exactly on a round schedule must advance past it, because the + // close requires the round to be strictly in the future. + next, err := FirstQuicknetRoundAfter(scheduled) + if err != nil { + t.Fatal(err) + } + if next != round+1 { + t.Fatalf("round %d schedule derived %d, want %d", round, next, round+1) + } + nextTime, err := QuicknetRoundTime(next) + if err != nil { + t.Fatal(err) + } + if !nextTime.After(scheduled) { + t.Fatalf("derived round %d is not after %s", next, scheduled) + } + } +} + +func TestFirstQuicknetRoundAfterMidPeriod(t *testing.T) { + base, err := QuicknetRoundTime(1000) + if err != nil { + t.Fatal(err) + } + // One second into a three-second period still resolves to the next round. + next, err := FirstQuicknetRoundAfter(base.Add(time.Second)) + if err != nil { + t.Fatal(err) + } + if next != 1001 { + t.Fatalf("mid-period derived %d, want 1001", next) + } +} + +func TestFirstQuicknetRoundAfterBeforeGenesis(t *testing.T) { + round, err := FirstQuicknetRoundAfter(time.Unix(BeaconQuicknetGenesis-3600, 0)) + if err != nil { + t.Fatal(err) + } + if round != 1 { + t.Fatalf("pre-genesis derived %d, want 1", round) + } +} + +// TestDerivedRoundClearsTheSignedLead is the property the fix exists for: a +// round derived from the post-replay clock must satisfy the same lead check +// that rejects a round an operator named before a multi-hour replay. +func TestDerivedRoundClearsTheSignedLead(t *testing.T) { + const leadSeconds = 600 + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: leadSeconds}, + } + closedAt := time.Unix(BeaconQuicknetGenesis+1_000_000, 0).UTC() + lead := leadSeconds * time.Second + + round, err := FirstQuicknetRoundAfter(closedAt.Add(lead + closePublicationSafetyMargin)) + if err != nil { + t.Fatal(err) + } + roundTime, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + if err := validateCloseCommitTime(closedAt, closedAt, roundTime, definition); err != nil { + t.Fatalf("derived round rejected by the publication guard: %v", err) + } + if roundTime.Sub(closedAt) < lead { + t.Fatalf("derived lead %s is below the signed minimum %s", roundTime.Sub(closedAt), lead) + } +} + +// TestExplicitRoundStaleAfterLongReplayIsRejected reproduces the failure the +// derivation avoids: a round chosen before an hours-long replay is already in +// the past when the closure is published. +func TestExplicitRoundStaleAfterLongReplayIsRejected(t *testing.T) { + const leadSeconds = 600 + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: leadSeconds}, + } + chosenAt := time.Unix(BeaconQuicknetGenesis+1_000_000, 0).UTC() + + // The operator picks a round just past the signed lead, as the only written + // rule suggests. + round, err := FirstQuicknetRoundAfter(chosenAt.Add(leadSeconds * time.Second)) + if err != nil { + t.Fatal(err) + } + roundTime, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + + // The replay then takes an hour and forty minutes. + closedAt := chosenAt.Add(100 * time.Minute) + if err := validateCloseCommitTime(closedAt, closedAt, roundTime, definition); err == nil { + t.Fatal("stale round was accepted after a long replay") + } + + // Deriving from the post-replay clock instead succeeds on the same timeline. + derived, err := FirstQuicknetRoundAfter( + closedAt.Add(leadSeconds*time.Second + closePublicationSafetyMargin), + ) + if err != nil { + t.Fatal(err) + } + derivedTime, err := QuicknetRoundTime(derived) + if err != nil { + t.Fatal(err) + } + if err := validateCloseCommitTime(closedAt, closedAt, derivedTime, definition); err != nil { + t.Fatalf("derived round rejected: %v", err) + } +} diff --git a/internal/mpcceremony/canonical.go b/internal/mpcceremony/canonical.go new file mode 100644 index 0000000..940b9d8 --- /dev/null +++ b/internal/mpcceremony/canonical.go @@ -0,0 +1,59 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import "fmt" + +// The reviewed identity of the production destination-v2 circuit, as compiled +// from the patched vendor tree that scripts/bootstrap-vendor.sh reconstructs. +// +// A build made without that vendor tree resolves upstream gnark from the +// module cache and compiles a slightly different circuit (observed: +// 1,791,413 constraints instead of 1,789,750), because reviewed patches such +// as the uints constant folding change the constraint system. Nothing about +// such a build fails on its own: init would sign the wrong circuit into the +// ceremony definition and every later stage would coherently verify against +// it. These constants let production init reject that fork at the source +// instead of discovering it after hours of ceremony compute. +// +// Update these values only when the reviewed circuit intentionally changes, +// together with the vendor patches and the release review that approves the +// new identity. Production definition validation calls this function, so the +// invariant is enforced when definitions are created or consumed rather than +// only by the init command. +const ( + CanonicalDestinationV2SHA256 = "sha256:b5e629f47321048a6e2f85b3a839c1cf898454b69eef582f54e07d6d647074dc" + CanonicalDestinationV2Blake2b256 = "blake2b256:bf2243b3f4885357bbad0b6728582f56f0e00cd361e1e8af8a2d0dbe10a9f352" + CanonicalDestinationV2Size = int64(129221468) + CanonicalDestinationV2Constraints = uint64(1789750) +) + +// ValidateCanonicalDestinationV2 rejects a compiled destination-v2 circuit +// whose serialized identity differs from the reviewed canonical build. It says +// nothing about other key versions: the rehearsal circuit is deliberately not +// pinned here. +func ValidateCanonicalDestinationV2(binding CircuitBinding) error { + if binding.KeyVersion != KeyVersionDestinationV2 { + return nil + } + if binding.Constraints != CanonicalDestinationV2Constraints { + return fmt.Errorf( + "compiled destination-v2 circuit has %d constraints, want canonical %d; "+ + "rebuild from the patched vendor tree (scripts/bootstrap-vendor.sh, then go build -mod=vendor)", + binding.Constraints, + CanonicalDestinationV2Constraints, + ) + } + if binding.R1CS.Digest.SHA256 != CanonicalDestinationV2SHA256 || + binding.R1CS.Digest.Blake2b256 != CanonicalDestinationV2Blake2b256 || + binding.R1CS.Digest.Size != CanonicalDestinationV2Size { + return fmt.Errorf( + "compiled destination-v2 R1CS digest %s (%d bytes) does not match the canonical reviewed build; "+ + "rebuild from the patched vendor tree (scripts/bootstrap-vendor.sh, then go build -mod=vendor)", + binding.R1CS.Digest.SHA256, + binding.R1CS.Digest.Size, + ) + } + return nil +} diff --git a/internal/mpcceremony/canonical_test.go b/internal/mpcceremony/canonical_test.go new file mode 100644 index 0000000..f53a717 --- /dev/null +++ b/internal/mpcceremony/canonical_test.go @@ -0,0 +1,59 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "strings" + "testing" +) + +func canonicalBinding() CircuitBinding { + return CircuitBinding{ + KeyVersion: KeyVersionDestinationV2, + R1CS: ArtifactRef{ + Name: "ownership-destination.ccs", + Digest: Digest{ + SHA256: CanonicalDestinationV2SHA256, + Blake2b256: CanonicalDestinationV2Blake2b256, + Size: CanonicalDestinationV2Size, + }, + }, + Constraints: CanonicalDestinationV2Constraints, + } +} + +func TestValidateCanonicalDestinationV2(t *testing.T) { + if err := ValidateCanonicalDestinationV2(canonicalBinding()); err != nil { + t.Fatalf("canonical binding rejected: %v", err) + } + + // The rehearsal circuit is not pinned by this check. + rehearsal := canonicalBinding() + rehearsal.KeyVersion = "rehearsal-tiny-v1" + rehearsal.Constraints = 5 + if err := ValidateCanonicalDestinationV2(rehearsal); err != nil { + t.Fatalf("non-destination-v2 binding rejected: %v", err) + } + + // The observed unvendored-build fork: same key version, different circuit. + unvendored := canonicalBinding() + unvendored.Constraints = 1791413 + err := ValidateCanonicalDestinationV2(unvendored) + if err == nil || !strings.Contains(err.Error(), "bootstrap-vendor.sh") { + t.Fatalf("unvendored constraint count accepted or unhelpful error: %v", err) + } + + mutations := []func(*CircuitBinding){ + func(b *CircuitBinding) { b.R1CS.Digest.SHA256 = "sha256:" + strings.Repeat("0", 64) }, + func(b *CircuitBinding) { b.R1CS.Digest.Blake2b256 = "blake2b256:" + strings.Repeat("0", 64) }, + func(b *CircuitBinding) { b.R1CS.Digest.Size = CanonicalDestinationV2Size + 1 }, + } + for i, mutate := range mutations { + binding := canonicalBinding() + mutate(&binding) + if err := ValidateCanonicalDestinationV2(binding); err == nil { + t.Fatalf("mutation %d accepted", i) + } + } +} diff --git a/internal/mpcceremony/chain.go b/internal/mpcceremony/chain.go index 4ed520f..e8f172b 100644 --- a/internal/mpcceremony/chain.go +++ b/internal/mpcceremony/chain.go @@ -9,6 +9,7 @@ import ( "hash" "math" "slices" + "strings" "time" ) @@ -587,9 +588,32 @@ func ValidateClose(definition CeremonyDefinition, chain Chain, close CloseRecord minimumLead, ) } + if requiredLead := requiredCloseLead(definition); roundTime.Sub(closedAt) < requiredLead { + return fmt.Errorf( + "beacon round lead %s does not reserve the production witness observation window: need %s (signed minimum %s plus %s window)", + roundTime.Sub(closedAt), + requiredLead, + minimumLead, + requiredLead-minimumLead, + ) + } return nil } +// requiredCloseLead is the beacon lead a close must reserve, measured from +// closed_at: the signed minimum witness lead, plus — in production — the +// witness observation window. Witness receipts measure the same signed +// minimum from their own observation time, which is strictly after closed_at, +// so without the reserved window a close at the bare minimum makes every +// witness receipt unsatisfiable. See ProductionWitnessObservationWindowSeconds. +func requiredCloseLead(definition CeremonyDefinition) time.Duration { + lead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second + if definition.Mode == ModeProduction { + lead += time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second + } + return lead +} + type BeaconRecord struct { Schema string `json:"schema"` BeaconID string `json:"beacon_id"` @@ -784,6 +808,34 @@ func QuicknetRoundTime(round uint64) (time.Time, error) { return time.Unix(seconds, 0).UTC(), nil } +// FirstQuicknetRoundAfter returns the earliest round whose scheduled time is +// strictly after the supplied instant. +// +// It is the inverse of QuicknetRoundTime and exists so a phase close can name +// its beacon round using the clock it sampled after replaying, rather than a +// round an operator had to guess before the replay began. Rounds are pure +// arithmetic from the pinned genesis and period, so this needs no network +// access and stays deterministic. +func FirstQuicknetRoundAfter(instant time.Time) (uint64, error) { + seconds := instant.UTC().Unix() + if seconds < BeaconQuicknetGenesis { + return 1, nil + } + period := int64(BeaconQuicknetPeriod) + elapsed := seconds - BeaconQuicknetGenesis + // Round index is one-based, and the result must be strictly after the + // instant, so a time landing exactly on a round schedule advances past it. + round := uint64(elapsed/period) + 2 + roundTime, err := QuicknetRoundTime(round) + if err != nil { + return 0, err + } + if !roundTime.After(instant) { + return 0, errors.New("derived beacon round is not after the supplied instant") + } + return round, nil +} + // DeriveBeaconChallenge maps authenticated public beacon randomness to the // exact 32-byte challenge supplied to gnark. Length prefixes make every input // tuple unambiguous and the domain tag prevents reuse in another protocol. @@ -1035,7 +1087,7 @@ func (r AuditRecord) validate(requireID bool) error { return errors.New("failed audit must contain at least one finding") } for _, finding := range r.Findings { - if finding == "" { + if strings.TrimSpace(finding) == "" { return errors.New("audit findings must not be empty") } } diff --git a/internal/mpcceremony/chain_test.go b/internal/mpcceremony/chain_test.go index ddc0065..48e5f4e 100644 --- a/internal/mpcceremony/chain_test.go +++ b/internal/mpcceremony/chain_test.go @@ -188,9 +188,13 @@ func TestValidateBeaconRecomputesAgainstBoundClose(t *testing.T) { BeaconNetwork: definition.BeaconPolicy.Network, BeaconRound: 30699432, BeaconNotBefore: roundTime.Format(time.RFC3339), - ClosedAt: roundTime.Add(-minimumLead - time.Minute).Format(time.RFC3339), - CoordinatorID: definition.Coordinator.ID, - CoordinatorKeyID: definition.Coordinator.KeyID, + ClosedAt: roundTime.Add( + -minimumLead - + time.Duration(ProductionWitnessObservationWindowSeconds)*time.Second - + time.Minute, + ).Format(time.RFC3339), + CoordinatorID: definition.Coordinator.ID, + CoordinatorKeyID: definition.Coordinator.KeyID, }) if err != nil { t.Fatal(err) @@ -198,14 +202,29 @@ func TestValidateBeaconRecomputesAgainstBoundClose(t *testing.T) { if err := ValidateClose(definition, chain, closeRecord); err != nil { t.Fatal(err) } + // Production must reserve the witness observation window on top of the + // signed minimum: witness receipts measure the same minimum from their + // observation time, which is strictly after closed_at, so a close at the + // bare minimum would make every witness receipt unsatisfiable. + window := time.Duration(ProductionWitnessObservationWindowSeconds) * time.Second exactLead := closeRecord exactLead.ClosedAt = roundTime.Add(-minimumLead).Format(time.RFC3339) exactLead, err = NewCloseRecord(exactLead) if err != nil { t.Fatal(err) } - if err := ValidateClose(definition, chain, exactLead); err != nil { - t.Fatalf("close at exact signed minimum witness lead rejected: %v", err) + if err := ValidateClose(definition, chain, exactLead); err == nil || + !strings.Contains(err.Error(), "witness observation window") { + t.Fatalf("production close at bare signed minimum error = %v, want witness-window rejection", err) + } + windowedLead := closeRecord + windowedLead.ClosedAt = roundTime.Add(-minimumLead - window).Format(time.RFC3339) + windowedLead, err = NewCloseRecord(windowedLead) + if err != nil { + t.Fatal(err) + } + if err := ValidateClose(definition, chain, windowedLead); err != nil { + t.Fatalf("production close reserving the witness window rejected: %v", err) } belowLead := exactLead belowLead.ClosedAt = "2026-07-23T14:01:00.000000001Z" diff --git a/internal/mpcceremony/close_timing_test.go b/internal/mpcceremony/close_timing_test.go index 3876675..d574fde 100644 --- a/internal/mpcceremony/close_timing_test.go +++ b/internal/mpcceremony/close_timing_test.go @@ -11,6 +11,10 @@ func TestValidateCloseCommitTimeBoundaries(t *testing.T) { roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) const minimumLead uint32 = 300 + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: minimumLead}, + } requiredLead := time.Duration(minimumLead)*time.Second + closePublicationSafetyMargin closedAt := roundTime.Add(-requiredLead - time.Second) @@ -18,7 +22,7 @@ func TestValidateCloseCommitTimeBoundaries(t *testing.T) { closedAt, roundTime.Add(-requiredLead), roundTime, - minimumLead, + definition, ); err != nil { t.Fatalf("exact publication boundary rejected: %v", err) } @@ -26,7 +30,7 @@ func TestValidateCloseCommitTimeBoundaries(t *testing.T) { closedAt, roundTime.Add(-requiredLead+time.Nanosecond), roundTime, - minimumLead, + definition, ); err == nil || !strings.Contains(err.Error(), "below required") { t.Fatalf("publication below boundary error = %v, want lead rejection", err) } @@ -37,11 +41,15 @@ func TestValidateCloseCommitTimeRejectsClockRollbackAndZeroTimes(t *testing.T) { roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) closedAt := roundTime.Add(-time.Hour) + definition := CeremonyDefinition{ + Mode: ModeRehearsal, + BeaconPolicy: BeaconPolicy{MinimumWitnessLeadSeconds: 300}, + } if err := validateCloseCommitTime( closedAt, closedAt.Add(-time.Nanosecond), roundTime, - 300, + definition, ); err == nil || !strings.Contains(err.Error(), "moved backwards") { t.Fatalf("clock rollback error = %v, want rollback rejection", err) } @@ -49,7 +57,7 @@ func TestValidateCloseCommitTimeRejectsClockRollbackAndZeroTimes(t *testing.T) { time.Time{}, closedAt, roundTime, - 300, + definition, ); err == nil || !strings.Contains(err.Error(), "zero time") { t.Fatalf("zero closed_at error = %v, want zero-time rejection", err) } @@ -57,8 +65,41 @@ func TestValidateCloseCommitTimeRejectsClockRollbackAndZeroTimes(t *testing.T) { closedAt, time.Time{}, roundTime, - 300, + definition, ); err == nil || !strings.Contains(err.Error(), "zero time") { t.Fatalf("zero commit time error = %v, want zero-time rejection", err) } } + +func TestValidateCloseCommitTimeReservesProductionWitnessWindow(t *testing.T) { + t.Parallel() + + roundTime := time.Date(2026, 7, 24, 12, 0, 0, 0, time.UTC) + definition := CeremonyDefinition{ + Mode: ModeProduction, + BeaconPolicy: BeaconPolicy{ + MinimumWitnessLeadSeconds: ProductionMinimumWitnessLeadSeconds, + }, + } + requiredLead := time.Duration( + ProductionMinimumWitnessLeadSeconds+ProductionWitnessObservationWindowSeconds, + )*time.Second + closePublicationSafetyMargin + closedAt := roundTime.Add(-requiredLead - time.Second) + + if err := validateCloseCommitTime( + closedAt, + roundTime.Add(-requiredLead), + roundTime, + definition, + ); err != nil { + t.Fatalf("production close reserving the witness window rejected: %v", err) + } + if err := validateCloseCommitTime( + closedAt, + roundTime.Add(-requiredLead+time.Second), + roundTime, + definition, + ); err == nil || !strings.Contains(err.Error(), "below required") { + t.Fatalf("production close without the witness window error = %v, want lead rejection", err) + } +} diff --git a/internal/mpcceremony/deceptive_names_test.go b/internal/mpcceremony/deceptive_names_test.go new file mode 100644 index 0000000..f9263a0 --- /dev/null +++ b/internal/mpcceremony/deceptive_names_test.go @@ -0,0 +1,146 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "strings" + "testing" +) + +// Built with string(rune(...)) rather than written literally: these characters +// are invisible, and two of them would reorder this source file in an editor. +var ( + rlo = string(rune(0x202E)) // right-to-left override + lro = string(rune(0x202D)) // left-to-right override + rli = string(rune(0x2067)) // right-to-left isolate + pdi = string(rune(0x2069)) // pop directional isolate + lrm = string(rune(0x200E)) // left-to-right mark + zwsp = string(rune(0x200B)) // zero-width space + zwnj = string(rune(0x200C)) // zero-width non-joiner, legitimate + zwj = string(rune(0x200D)) // zero-width joiner, legitimate + esc = string(rune(0x001B)) // ANSI escape introducer + bel = string(rune(0x0007)) // bell +) + +// identityWithDisplayName builds an otherwise valid identity so the only thing +// under test is the display name. +func identityWithDisplayName(t *testing.T, displayName string) Identity { + t.Helper() + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + return Identity{ + ID: "participant-01", + DisplayName: displayName, + KeyID: "participant-01-key", + Ed25519PublicKeyHex: hex.EncodeToString(public), + PublicKeyFingerprint: taggedSHA256(public), + } +} + +// TestDisplayNameRejectsDeceptiveRunes covers the characters that make a signed +// value render as something other than its bytes. None of these forge anything: +// the target is the human reading a transcript, and the audit and release steps +// depend on that reading being accurate. +func TestDisplayNameRejectsDeceptiveRunes(t *testing.T) { + cases := []struct { + name string + displayName string + wantErr string + }{ + // Stored bytes read "ecilA"; a terminal renders "Alice". + {"right-to-left override", rlo + "ecilA", "bidirectional formatting"}, + {"left-to-right override", "Alice" + lro, "bidirectional formatting"}, + {"right-to-left isolate", "Alice" + rli + "Chen", "bidirectional formatting"}, + {"pop directional isolate", "Alice" + pdi, "bidirectional formatting"}, + {"left-to-right mark", "Alice" + lrm + "Chen", "bidirectional formatting"}, + // Renders identically to a plain "Alice", so two roster entries become + // indistinguishable on screen. + {"zero width space", "Ali" + zwsp + "ce", "zero-width"}, + {"ansi escape", "Alice" + esc + "[2K", "control character"}, + {"bell", "Alice" + bel, "control character"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + err := identityWithDisplayName(t, testCase.displayName).Validate() + if err == nil { + t.Fatalf("display name %q was accepted", testCase.displayName) + } + if !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("error %q does not mention %q", err, testCase.wantErr) + } + }) + } +} + +// TestDisplayNameAcceptsLegitimateText guards against over-blocking. ZWNJ and +// ZWJ are category Cf like the rejected characters, but they carry meaning: +// U+200C separates Persian and Indic letterforms and U+200D joins emoji +// sequences. Rejecting all of category Cf would make these names unwritable. +func TestDisplayNameAcceptsLegitimateText(t *testing.T) { + for _, displayName := range []string{ + "Alice Chen", + "Alice Chen, ZK Security", + "Zoe Muller", + "田中太郎", + "مريم", + "می" + zwnj + "خواهم", + "\U0001F469" + zwj + "\U0001F4BB", + strings.Repeat("a", maxDisplayNameBytes), + } { + if err := identityWithDisplayName(t, displayName).Validate(); err != nil { + t.Fatalf("legitimate display name %q was rejected: %v", displayName, err) + } + } +} + +func TestDisplayNameBounds(t *testing.T) { + cases := []struct { + name string + displayName string + wantErr string + }{ + {"empty", "", "1 to 256 bytes"}, + {"too long", strings.Repeat("a", maxDisplayNameBytes+1), "1 to 256 bytes"}, + {"blank", " ", "must be trimmed"}, + {"untrimmed", " Alice ", "must be trimmed"}, + {"invalid utf8", "Alice\xff", "valid UTF-8"}, + } + for _, testCase := range cases { + t.Run(testCase.name, func(t *testing.T) { + err := identityWithDisplayName(t, testCase.displayName).Validate() + if err == nil { + t.Fatalf("display name %q was accepted", testCase.displayName) + } + if !strings.Contains(err.Error(), testCase.wantErr) { + t.Fatalf("error %q does not mention %q", err, testCase.wantErr) + } + }) + } +} + +// TestArtifactNameRejectsDeceptiveRunes covers the other half of the same gap. +// Artifact names were already screened with unicode.IsControl, which reports +// category Cc only, so every bidi and zero-width character (category Cf) passed +// until rejectDeceptiveRunes was shared between the two validators. +func TestArtifactNameRejectsDeceptiveRunes(t *testing.T) { + for _, name := range []string{ + "phase1/" + rlo + "gnp.nib", + "phase1/chain" + zwsp + "-0001.json", + "phase1/" + rli + "chain.json", + } { + if err := validateArtifactName(name); err == nil { + t.Fatalf("artifact name %q was accepted", name) + } + } + for _, name := range []string{ + "phase1/chain-0001.json", + "phase1/beacon/record.json", + "ownership-destination.ccs", + } { + if err := validateArtifactName(name); err != nil { + t.Fatalf("legitimate artifact name %q was rejected: %v", name, err) + } + } +} diff --git a/internal/mpcceremony/decision.go b/internal/mpcceremony/decision.go index d6c4025..c51f871 100644 --- a/internal/mpcceremony/decision.go +++ b/internal/mpcceremony/decision.go @@ -24,7 +24,14 @@ const ( ProductionDecisionSchema = "proof-tool-mpc-production-decision-v1" ProductionDecisionDraftSchema = "proof-tool-mpc-production-decision-draft-v1" ProductionDecisionSignatureSchema = "proof-tool-mpc-production-decision-signature-v1" - MaxProductionReleaseArtifacts = 4096 + // MaxProductionReleaseArtifacts must admit the largest release tree the + // earlier layers can produce, or a fully valid signed release strands at + // decision preparation. Upper bound of the evidence a bundle may reference: + // up to 128 governance records with up to 128 evidence artifacts each + // (~16.5k), plus enrollments (128 x 3), witness receipts (32 x 2 phases x 2 + // files), mirror receipts (8 x 20 heads x 2 phases), relay evidence, audits, + // and the fixed candidate set — comfortably under 32768. + MaxProductionReleaseArtifacts = 32768 ) type ProductionDecisionOutcome string @@ -47,7 +54,7 @@ type ProductionGate string const ( GateSignedRelease ProductionGate = "signed-release" GateOperationalEvidence ProductionGate = "operational-evidence" - GateIndependentAudits ProductionGate = "two-independent-audits" + GateIndependentAudits ProductionGate = "independent-audits" GateExternalAudit ProductionGate = "third-party-security-audit" GateK21Rehearsal ProductionGate = "exact-k21-rehearsal" GateMainnetDeploymentPlan ProductionGate = "mainnet-deployment-plan" @@ -484,9 +491,16 @@ func (d ProductionDecision) Validate() error { if err := d.OperationalEvidence.Validate(); err != nil { return fmt.Errorf("operational_evidence: %w", err) } - if len(d.Audits) != 2 { - return fmt.Errorf("production decision requires exactly two audits, got %d", len(d.Audits)) + // Two is the floor, not the ceiling. A ceremony may enroll more than two + // auditors (definition.go requires at least two), and SignRelease accepts + // every passing report it is given. Demanding exactly two here would let a + // three-auditor ceremony produce a valid signed release that could never be + // recorded in a valid decision, and the failure would only surface at final + // GO signing when nothing can be redone. + if len(d.Audits) < 2 { + return fmt.Errorf("production decision requires at least two audits, got %d", len(d.Audits)) } + auditKeyIDs := make(map[string]struct{}, len(d.Audits)) for index, audit := range d.Audits { if err := audit.Validate(); err != nil { return fmt.Errorf("audit %d: %w", index, err) @@ -494,13 +508,15 @@ func (d ProductionDecision) Validate() error { if index > 0 && audit.AuditorID <= d.Audits[index-1].AuditorID { return errors.New("audits must be ordered by distinct auditor_id") } + if _, duplicate := auditKeyIDs[audit.AuditorKeyID]; duplicate { + return errors.New("production audit key ids must be distinct") + } + auditKeyIDs[audit.AuditorKeyID] = struct{}{} } - if d.Audits[0].AuditorKeyID == d.Audits[1].AuditorKeyID { - return errors.New("production audit key ids must be distinct") - } - if len(d.ExternalAudits) != 2 { - return fmt.Errorf("production decision requires exactly two external audits, got %d", len(d.ExternalAudits)) + if len(d.ExternalAudits) < 2 { + return fmt.Errorf("production decision requires at least two external audits, got %d", len(d.ExternalAudits)) } + externalFingerprints := make(map[string]struct{}, len(d.ExternalAudits)) for index, external := range d.ExternalAudits { if err := external.Validate(); err != nil { return fmt.Errorf("external audit %d: %w", index, err) @@ -508,10 +524,10 @@ func (d ProductionDecision) Validate() error { if index > 0 && external.Auditor.ID <= d.ExternalAudits[index-1].Auditor.ID { return errors.New("external audits must be ordered by distinct auditor identity") } - } - if d.ExternalAudits[0].Auditor.PublicKeyFingerprint == - d.ExternalAudits[1].Auditor.PublicKeyFingerprint { - return errors.New("external audit signer keys must be distinct") + if _, duplicate := externalFingerprints[external.Auditor.PublicKeyFingerprint]; duplicate { + return errors.New("external audit signer keys must be distinct") + } + externalFingerprints[external.Auditor.PublicKeyFingerprint] = struct{}{} } if err := d.K21Rehearsal.Validate(); err != nil { return err @@ -940,9 +956,12 @@ func verifyDecisionRelease(definition CeremonyDefinition, decision ProductionDec if err := UnmarshalCanonical(transcriptBytes, &transcript); err != nil { return fmt.Errorf("final transcript: %w", err) } - expectedAuditRefs := []ArtifactRef{ - releaseLogicalArtifact(releaseDirName, decision.Audits[0].Audit.Record.Artifact), - releaseLogicalArtifact(releaseDirName, decision.Audits[1].Audit.Record.Artifact), + expectedAuditRefs := make([]ArtifactRef, 0, len(decision.Audits)) + for _, audit := range decision.Audits { + expectedAuditRefs = append( + expectedAuditRefs, + releaseLogicalArtifact(releaseDirName, audit.Audit.Record.Artifact), + ) } expectedOperationalRefs := SignedArtifactRefs{ Record: releaseLogicalArtifact( @@ -1244,19 +1263,24 @@ func decisionSignerIdentity( return identity, nil } } - return Identity{}, errors.New("auditor decision signature is not from either audit bound by the decision") + return Identity{}, errors.New("auditor decision signature is not from any audit bound by the decision") default: return Identity{}, fmt.Errorf("unsupported decision signer role %q", role) } } +// requiredDecisionSigners lists every signature a GO decision must carry. +// Every named auditor is required, not just the first two: the decision accepts +// two or more audits, and an auditor whose report is bound into the decision but +// whose consent is not required would be recorded as having reviewed the release +// without having agreed to it. func requiredDecisionSigners(definition CeremonyDefinition, decision ProductionDecision) []string { - return []string{ - string(DecisionSignerCoordinator) + "\x00" + definition.Coordinator.ID, - string(DecisionSignerAuditor) + "\x00" + decision.Audits[0].AuditorID, - string(DecisionSignerAuditor) + "\x00" + decision.Audits[1].AuditorID, - string(DecisionSignerRelease) + "\x00" + definition.ReleaseSigner.ID, + required := make([]string, 0, len(decision.Audits)+2) + required = append(required, string(DecisionSignerCoordinator)+"\x00"+definition.Coordinator.ID) + for _, audit := range decision.Audits { + required = append(required, string(DecisionSignerAuditor)+"\x00"+audit.AuditorID) } + return append(required, string(DecisionSignerRelease)+"\x00"+definition.ReleaseSigner.ID) } func validateLocatedArtifactCoherence(decision ProductionDecision) error { @@ -1286,14 +1310,13 @@ func allLocatedArtifacts(decision ProductionDecision) []LocatedArtifactRef { decision.SourceRelease.SignedTagObject, decision.OperationalEvidence.Record, decision.OperationalEvidence.Signature, - decision.Audits[0].Audit.Record, - decision.Audits[0].Audit.Signature, - decision.Audits[1].Audit.Record, - decision.Audits[1].Audit.Signature, decision.K21Rehearsal.Evidence, decision.MainnetDeploymentPlan, decision.FormalChecklist, } + for _, audit := range decision.Audits { + refs = append(refs, audit.Audit.Record, audit.Audit.Signature) + } refs = append(refs, decision.Release.Artifacts...) for _, external := range decision.ExternalAudits { refs = append(refs, external.Report, external.Signoff) diff --git a/internal/mpcceremony/decision_test.go b/internal/mpcceremony/decision_test.go index c227d06..5276f3b 100644 --- a/internal/mpcceremony/decision_test.go +++ b/internal/mpcceremony/decision_test.go @@ -153,7 +153,7 @@ func TestSignedReleaseInventorySupportsTwentyPartyOperationalScale(t *testing.T) } } if _, err := NewSignedReleaseEvidence(input); err == nil || - !strings.Contains(err.Error(), "4096") { + !strings.Contains(err.Error(), fmt.Sprint(MaxProductionReleaseArtifacts)) { t.Fatalf("oversized release inventory error = %v", err) } } diff --git a/internal/mpcceremony/definition.go b/internal/mpcceremony/definition.go index cd39d22..22d1f46 100644 --- a/internal/mpcceremony/definition.go +++ b/internal/mpcceremony/definition.go @@ -7,6 +7,21 @@ import ( const ProductionMinimumWitnessLeadSeconds uint32 = 24 * 60 * 60 +// ProductionWitnessObservationWindowSeconds is the observation time a +// production close must reserve for public witnesses on top of the signed +// minimum witness lead. +// +// The signed minimum is measured from two different anchors: ValidateClose +// measures roundTime-closedAt, while witness receipts measure +// roundTime-observedAt with observedAt strictly after closedAt. A close at +// exactly the signed minimum therefore leaves witnesses no time in which a +// valid receipt can exist, and the mismatch surfaces only when the evidence +// bundle is assembled at release, when the round is already pinned inside the +// 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 + type CeremonyDefinition struct { Schema string `json:"schema"` CeremonyID string `json:"ceremony_id"` @@ -122,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") } @@ -149,6 +178,11 @@ func (d CeremonyDefinition) validate(requireID bool) error { if err := d.Circuit.Validate(); err != nil { return fmt.Errorf("circuit: %w", err) } + if d.Mode == ModeProduction { + if err := ValidateCanonicalDestinationV2(d.Circuit); err != nil { + return fmt.Errorf("circuit: %w", err) + } + } if err := d.Software.Validate(); err != nil { return fmt.Errorf("software: %w", err) } @@ -164,6 +198,9 @@ func (d CeremonyDefinition) validate(requireID bool) error { if len(d.Auditors) < 2 { return errors.New("at least two independent auditors are required") } + if len(d.Auditors) > MaxAuditors { + return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) + } identityIDs := map[string]string{ d.Coordinator.ID: "coordinator", d.ReleaseSigner.ID: "release signer", diff --git a/internal/mpcceremony/definition_gate_test.go b/internal/mpcceremony/definition_gate_test.go new file mode 100644 index 0000000..b73144f --- /dev/null +++ b/internal/mpcceremony/definition_gate_test.go @@ -0,0 +1,34 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "fmt" + "strings" + "testing" +) + +// TestDefinitionBoundsAuditorsToTranscriptCapacity pins the M2 gate sweep +// fix: the final transcript records audits in a list capped at MaxAuditors, +// so enrollment must reject what the transcript cannot record instead of +// letting release sign discover it after every audit has been performed. +func TestDefinitionBoundsAuditorsToTranscriptCapacity(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + for index := len(definition.Auditors); index < MaxAuditors+1; index++ { + definition.Auditors = append(definition.Auditors, adversarialIdentity( + t, + fmt.Sprintf("auditor-%02d", index+1), + byte(0x40+index), + )) + } + if _, err := FinalizeCeremonyDefinition(definition); err == nil || + !strings.Contains(err.Error(), "exceed maximum") { + t.Fatalf("definition with %d auditors error = %v, want transcript-capacity rejection", MaxAuditors+1, err) + } + definition.Auditors = definition.Auditors[:MaxAuditors] + if _, err := FinalizeCeremonyDefinition(definition); err != nil { + t.Fatalf("definition with exactly %d auditors rejected: %v", MaxAuditors, err) + } +} diff --git a/internal/mpcceremony/definition_test.go b/internal/mpcceremony/definition_test.go index 91618ad..24ee258 100644 --- a/internal/mpcceremony/definition_test.go +++ b/internal/mpcceremony/definition_test.go @@ -1,6 +1,40 @@ package mpcceremony -import "testing" +import ( + "strings" + "testing" +) + +func TestProductionDefinitionRequiresCanonicalDestinationCircuit(t *testing.T) { + tests := []struct { + name string + mutate func(*CircuitBinding) + }{ + { + name: "unvendored constraint count", + mutate: func(binding *CircuitBinding) { + binding.Constraints = 1791413 + }, + }, + { + name: "different R1CS digest", + mutate: func(binding *CircuitBinding) { + binding.R1CS.Digest.SHA256 = "sha256:" + strings.Repeat("0", 64) + }, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + definition := adversarialDefinition(t) + definition.CeremonyID = "" + test.mutate(&definition.Circuit) + if _, err := FinalizeCeremonyDefinition(definition); err == nil { + t.Fatal("production definition with noncanonical destination circuit unexpectedly accepted") + } + }) + } +} func TestProductionDefinitionRequiresMultipleParticipantsInBothPhases(t *testing.T) { valid := adversarialDefinition(t) diff --git a/internal/mpcceremony/direct_acceptance_boundary_test.go b/internal/mpcceremony/direct_acceptance_boundary_test.go index f684a68..cfcf817 100644 --- a/internal/mpcceremony/direct_acceptance_boundary_test.go +++ b/internal/mpcceremony/direct_acceptance_boundary_test.go @@ -44,6 +44,7 @@ func TestCoordinatorDirectTransitionProtocolBoundaries(t *testing.T) { fixture.ceremonyRoot, chain, fixture.circuit.Binding.DomainSize, + nil, )(0) if err != nil { t.Fatalf("load authenticated Phase 1 head: %v", err) @@ -95,6 +96,7 @@ func TestCoordinatorDirectTransitionProtocolBoundaries(t *testing.T) { fixture.ceremonyRoot, chain, contributionPhase2Shape(fixture.circuit.Binding.Phase2Shape), + nil, )(0) if err != nil { t.Fatalf("load authenticated Phase 2 head: %v", err) diff --git a/internal/mpcceremony/identity_key_test.go b/internal/mpcceremony/identity_key_test.go new file mode 100644 index 0000000..0d33d80 --- /dev/null +++ b/internal/mpcceremony/identity_key_test.go @@ -0,0 +1,86 @@ +package mpcceremony + +import ( + "crypto/ed25519" + "encoding/hex" + "strings" + "testing" +) + +// smallOrderEd25519Keys are the canonical encodings of the eight points of +// order dividing 8 on edwards25519, plus the two non-canonical encodings of the +// small-order points that decode successfully. Any of them, enrolled as an +// identity, makes signatures under that identity forgeable without a private +// key. +var smallOrderEd25519Keys = []string{ + "0100000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000000", + "0000000000000000000000000000000000000000000000000000000000000080", + "ecffffffffffffffffffffffffffffffffffffffffffffffffffffffffffff7f", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc05", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac03fa", + "26e8958fc2b227b045c3f489f2ef98f0d5dfac05d3c63339b13802886d53fc85", + "c7176a703d4dd84fba3c0b760d10670f2a2053fa2c39ccc64ec7fd7792ac037a", +} + +func TestIdentityRejectsSmallOrderPublicKeys(t *testing.T) { + for _, keyHex := range smallOrderEd25519Keys { + raw, err := hex.DecodeString(keyHex) + if err != nil { + t.Fatalf("decode %s: %v", keyHex, err) + } + if _, err := NewIdentity("participant-01", "Participant One", "participant-key", raw); err == nil { + t.Fatalf("NewIdentity accepted small-order public key %s", keyHex) + } + } +} + +// TestSmallOrderPublicKeyAcceptsForgedSignature documents why the check above +// exists. Without it, this signature verifies against the identity point for +// any message at all. +func TestSmallOrderPublicKeyAcceptsForgedSignature(t *testing.T) { + identityPoint := make([]byte, ed25519.PublicKeySize) + identityPoint[0] = 0x01 + + forged := make([]byte, ed25519.SignatureSize) + forged[0] = 0x01 // R = identity encoding, S = 0 + + for _, message := range []string{"one message", "an entirely different message"} { + if !ed25519.Verify(identityPoint, []byte(message), forged) { + t.Fatalf("expected the forged signature to verify for %q; the premise of the guard no longer holds", message) + } + } + + if err := validateEd25519PublicKey(identityPoint); err == nil { + t.Fatal("validateEd25519PublicKey accepted the identity point") + } +} + +func TestIdentityRejectsOffCurvePublicKey(t *testing.T) { + // High bit of the final byte is the sign of x; the remaining field element + // is not a valid y coordinate for any curve point. + raw, err := hex.DecodeString("0200000000000000000000000000000000000000000000000000000000000000") + if err != nil { + t.Fatalf("decode: %v", err) + } + err = validateEd25519PublicKey(raw) + if err == nil { + t.Fatal("validateEd25519PublicKey accepted an off-curve encoding") + } + if !strings.Contains(err.Error(), "curve point") { + t.Fatalf("unexpected error %v", err) + } +} + +func TestIdentityAcceptsGeneratedKey(t *testing.T) { + public, _, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate key: %v", err) + } + if err := validateEd25519PublicKey(public); err != nil { + t.Fatalf("validateEd25519PublicKey rejected a freshly generated key: %v", err) + } + if _, err := NewIdentity("participant-01", "Participant One", "participant-key", public); err != nil { + t.Fatalf("NewIdentity rejected a freshly generated key: %v", err) + } +} diff --git a/internal/mpcceremony/inspect.go b/internal/mpcceremony/inspect.go new file mode 100644 index 0000000..56a027b --- /dev/null +++ b/internal/mpcceremony/inspect.go @@ -0,0 +1,362 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "errors" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" +) + +// InspectDepthMetadata verifies signatures and structure only: the signed +// definition, the highest published chain per phase, and closure, beacon, and +// seal records when present. Artifact presence is checked by name and size; +// no artifact bytes are hashed and nothing is replayed. Seconds at any K. +const InspectDepthMetadata = "metadata" + +// InspectDepthFull additionally re-verifies every chain file the way the +// operational commands do: every payload digest, attestation, erasure, and +// coordinator verification record. It does not re-run the gnark replay; that +// remains the job of contribute, verify, and audit. +const InspectDepthFull = "full" + +// InspectCeremonyOptions configures the read-only ceremony inspection. +type InspectCeremonyOptions struct { + Trust TrustPaths + TranscriptRoot string + // Full selects InspectDepthFull. The default is InspectDepthMetadata. + Full bool +} + +// PhaseInspection reports the recovered state of one phase. +type PhaseInspection struct { + Phase Phase + Started bool + // ChainFile is the transcript-relative chain that was inspected: the + // highest index for which both the chain and its signature exist. The + // filename index must equal the record count, so a stale or renamed + // chain cannot claim another position. + ChainFile string + AcceptedCount int + // ScheduledTotal is the frozen participant schedule length. + ScheduledTotal int + HeadRecordID string + HeadPayload string + // NextIndex and NextParticipantID name the only contribution the frozen + // order permits next; both are zero values once every scheduled + // participant has been accepted. + NextIndex int + NextParticipantID string + ContributionsComplete bool + Closed bool + BeaconRecorded bool + Sealed bool + // MissingArtifacts lists referenced artifacts that are absent or have the + // wrong size. Empty means every referenced artifact is present. + MissingArtifacts []string +} + +// InspectResult is the full read-only inspection report. +type InspectResult struct { + CeremonyID string + Mode string + Depth string + Phases []PhaseInspection +} + +// InspectCeremony reports ceremony state from already-signed data: ceremony +// identity and mode, per-phase accepted count and head, the next scheduled +// participant, and which referenced artifacts are present. It requires no +// signing key, writes nothing, and never replays contributions. +// +// Unlike every other command, inspect discovers the highest published chain +// file per phase instead of taking explicit chain paths. That discovery is +// safe here and only here because inspection is read-only: its output feeds +// no signing or verification decision, every discovered file is still +// authenticated against the out-of-band trust anchor before being reported, +// and the chain filename index must match the signed record count. +func InspectCeremony(options InspectCeremonyOptions) (InspectResult, error) { + var result InspectResult + trusted, err := loadOperationalCeremony(options.Trust) + if err != nil { + return result, err + } + if strings.TrimSpace(options.TranscriptRoot) == "" { + return result, errors.New("transcript root is required") + } + result.CeremonyID = trusted.Definition.CeremonyID + result.Mode = trusted.Definition.Mode + result.Depth = InspectDepthMetadata + if options.Full { + result.Depth = InspectDepthFull + } + + var circuit *CompiledCircuit + if options.Full { + r1csPath := filepath.Join( + options.TranscriptRoot, + filepath.FromSlash(trusted.Definition.Circuit.R1CS.Name), + ) + circuit, err = ReadR1CSFile(r1csPath, trusted.Definition.Circuit) + if err != nil { + return result, fmt.Errorf("full inspection requires the pinned R1CS: %w", err) + } + } + + phase1, phase1Seal, err := inspectPhase(trusted, circuit, options, Phase1, nil) + if err != nil { + return result, fmt.Errorf("inspect phase1: %w", err) + } + result.Phases = append(result.Phases, phase1) + + phase2, _, err := inspectPhase(trusted, circuit, options, Phase2, phase1Seal) + if err != nil { + return result, fmt.Errorf("inspect phase2: %w", err) + } + result.Phases = append(result.Phases, phase2) + return result, nil +} + +func inspectPhase( + trusted *TrustedCeremony, + circuit *CompiledCircuit, + options InspectCeremonyOptions, + phase Phase, + phase1Seal *SealRecord, +) (PhaseInspection, *SealRecord, error) { + inspection := PhaseInspection{Phase: phase} + phaseDir := filepath.Join(options.TranscriptRoot, string(phase)) + if _, err := os.Lstat(phaseDir); errors.Is(err, fs.ErrNotExist) { + return inspection, nil, nil + } else if err != nil { + return inspection, nil, fmt.Errorf("inspect phase directory: %w", err) + } + + chainIndex := -1 + var chainPath, chainSignaturePath string + for index := 0; index <= MaxParticipants; index++ { + candidate := filepath.Join(phaseDir, fmt.Sprintf("chain-%04d.json", index)) + signature := DefaultSignaturePath(candidate) + if fileExists(candidate) && fileExists(signature) { + chainIndex = index + chainPath, chainSignaturePath = candidate, signature + } + } + if chainIndex < 0 { + return inspection, nil, nil + } + inspection.Started = true + inspection.ChainFile = filepath.ToSlash( + filepath.Join(string(phase), fmt.Sprintf("chain-%04d.json", chainIndex)), + ) + + chain, err := LoadSignedChain(trusted, PhaseTranscriptPaths{ + RootDir: options.TranscriptRoot, + ChainPath: chainPath, + ChainSignaturePath: chainSignaturePath, + }) + if err != nil { + return inspection, nil, fmt.Errorf("chain %s: %w", inspection.ChainFile, err) + } + if chain.Phase != phase { + return inspection, nil, fmt.Errorf("chain %s is for phase %q", inspection.ChainFile, chain.Phase) + } + if len(chain.Records) != chainIndex { + return inspection, nil, fmt.Errorf( + "chain %s holds %d records; the filename index requires exactly %d", + inspection.ChainFile, len(chain.Records), chainIndex, + ) + } + + policy, err := trusted.Definition.PolicyForPhase(phase) + if err != nil { + return inspection, nil, err + } + inspection.AcceptedCount = len(chain.Records) + inspection.ScheduledTotal = len(policy.Participants) + headID, err := chain.HeadRecordID() + if err != nil { + return inspection, nil, err + } + headPayload, err := chain.HeadPayload() + if err != nil { + return inspection, nil, err + } + inspection.HeadRecordID = headID + inspection.HeadPayload = headPayload.Name + if len(chain.Records) < len(policy.Participants) { + inspection.NextIndex = len(chain.Records) + 1 + inspection.NextParticipantID = policy.Participants[len(chain.Records)] + } else { + inspection.ContributionsComplete = true + } + + inspection.MissingArtifacts = missingChainArtifacts(options.TranscriptRoot, chain) + + closeRecord, closed, err := inspectCloseRecord(trusted, phaseDir, chain) + if err != nil { + return inspection, nil, err + } + inspection.Closed = closed + + var beacon BeaconRecord + if closed { + beaconRecorded, err := inspectBeaconRecord(trusted, phaseDir, closeRecord, &beacon) + if err != nil { + return inspection, nil, err + } + inspection.BeaconRecorded = beaconRecorded + } + + var seal *SealRecord + if phase == Phase1 && inspection.BeaconRecorded { + sealed, loadedSeal, err := inspectSealRecord(trusted, options.TranscriptRoot, closeRecord, beacon) + if err != nil { + return inspection, nil, err + } + inspection.Sealed = sealed + seal = loadedSeal + } + + if options.Full { + if err := inspectFullDepth(trusted, circuit, options.TranscriptRoot, phase, chainPath, chainSignaturePath, phase1Seal); err != nil { + return inspection, nil, fmt.Errorf("full verification of %s: %w", inspection.ChainFile, err) + } + } + return inspection, seal, nil +} + +func inspectCloseRecord(trusted *TrustedCeremony, phaseDir string, chain Chain) (CloseRecord, bool, error) { + closePath := filepath.Join(phaseDir, closePublicationDirectoryName, closeRecordFilename) + closeSignature := filepath.Join(phaseDir, closePublicationDirectoryName, closeSignatureFilename) + if !fileExists(closePath) || !fileExists(closeSignature) { + return CloseRecord{}, false, nil + } + var closeRecord CloseRecord + if err := loadCoordinatorSignedRecord(trusted, closePath, closeSignature, &closeRecord); err != nil { + return CloseRecord{}, false, fmt.Errorf("closure record: %w", err) + } + if err := ValidateClose(trusted.Definition, chain, closeRecord); err != nil { + return CloseRecord{}, false, fmt.Errorf("closure record: %w", err) + } + return closeRecord, true, nil +} + +func inspectBeaconRecord( + trusted *TrustedCeremony, + phaseDir string, + closeRecord CloseRecord, + beacon *BeaconRecord, +) (bool, error) { + beaconPath := filepath.Join(phaseDir, "beacon", "record.json") + beaconSignature := filepath.Join(phaseDir, "beacon", "record.sig") + if !fileExists(beaconPath) || !fileExists(beaconSignature) { + return false, nil + } + if err := loadCoordinatorSignedRecord(trusted, beaconPath, beaconSignature, beacon); err != nil { + return false, fmt.Errorf("beacon record: %w", err) + } + if err := ValidateBeacon(trusted.Definition, closeRecord, *beacon); err != nil { + return false, fmt.Errorf("beacon record: %w", err) + } + return true, nil +} + +func inspectSealRecord( + trusted *TrustedCeremony, + transcriptRoot string, + closeRecord CloseRecord, + beacon BeaconRecord, +) (bool, *SealRecord, error) { + sealPath := filepath.Join(transcriptRoot, string(Phase1), "sealed", "seal.json") + sealSignature := filepath.Join(transcriptRoot, string(Phase1), "sealed", "seal.sig") + if !fileExists(sealPath) || !fileExists(sealSignature) { + return false, nil, nil + } + var seal SealRecord + if err := loadCoordinatorSignedRecord(trusted, sealPath, sealSignature, &seal); err != nil { + return false, nil, fmt.Errorf("seal record: %w", err) + } + if err := ValidateSeal(closeRecord, beacon, seal); err != nil { + return false, nil, fmt.Errorf("seal record: %w", err) + } + return true, &seal, nil +} + +// missingChainArtifacts reports referenced artifacts that are absent or whose +// size disagrees with the signed reference. Size is a presence check, not an +// integrity check: full depth re-hashes contents, metadata depth does not. +func missingChainArtifacts(root string, chain Chain) []string { + var missing []string + references := make([]ArtifactRef, 0, len(chain.Records)+1) + if chain.Phase == Phase1 { + references = append(references, chain.Genesis) + } + for _, record := range chain.Records { + references = append(references, record.OutputPayload) + } + for _, ref := range references { + path, err := resolveArtifactPath(root, ref.Name) + if err != nil { + missing = append(missing, fmt.Sprintf("%s (unresolvable: %v)", ref.Name, err)) + continue + } + info, err := os.Lstat(path) + switch { + case errors.Is(err, fs.ErrNotExist): + missing = append(missing, ref.Name) + case err != nil: + missing = append(missing, fmt.Sprintf("%s (unreadable: %v)", ref.Name, err)) + case info.Size() != ref.Digest.Size: + missing = append(missing, fmt.Sprintf( + "%s (size %d, signed reference requires %d)", + ref.Name, info.Size(), ref.Digest.Size, + )) + } + } + return missing +} + +func inspectFullDepth( + trusted *TrustedCeremony, + circuit *CompiledCircuit, + transcriptRoot string, + phase Phase, + chainPath, chainSignaturePath string, + phase1Seal *SealRecord, +) error { + paths := PhaseTranscriptPaths{ + RootDir: transcriptRoot, + ChainPath: chainPath, + ChainSignaturePath: chainSignaturePath, + } + if phase == Phase1 { + _, err := loadVerifiedPhase1Files(trusted, circuit, paths) + return err + } + if phase1Seal == nil { + return errors.New("phase2 full verification requires the verified phase1 seal") + } + sealPath := filepath.Join(transcriptRoot, string(Phase1), "sealed", "seal.json") + commons, seal, _, err := loadPhase1CommonsForPhase2( + trusted, + circuit, + transcriptRoot, + sealPath, + DefaultSignaturePath(sealPath), + ) + if err != nil { + return err + } + _, err = loadVerifiedPhase2Files(trusted, circuit, commons, seal, paths) + return err +} + +func fileExists(path string) bool { + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() +} diff --git a/internal/mpcceremony/inspect_test.go b/internal/mpcceremony/inspect_test.go new file mode 100644 index 0000000..3d9b4ef --- /dev/null +++ b/internal/mpcceremony/inspect_test.go @@ -0,0 +1,83 @@ +// Copyright 2026 Midgard Labs +// SPDX-License-Identifier: Apache-2.0 + +package mpcceremony + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func inspectTestRef(name string, size int64) ArtifactRef { + return ArtifactRef{ + Name: name, + Digest: Digest{ + SHA256: "sha256:" + strings.Repeat("ab", 32), + Blake2b256: "blake2b256:" + strings.Repeat("cd", 32), + Size: size, + }, + } +} + +func TestMissingChainArtifactsReportsAbsentAndWrongSize(t *testing.T) { + t.Parallel() + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "phase1"), 0o700); err != nil { + t.Fatal(err) + } + present := filepath.Join(root, "phase1", "genesis.bin") + if err := os.WriteFile(present, []byte("12345678"), 0o600); err != nil { + t.Fatal(err) + } + truncated := filepath.Join(root, "phase1", "contribution-0001.bin") + if err := os.WriteFile(truncated, []byte("123"), 0o600); err != nil { + t.Fatal(err) + } + + chain := Chain{ + Phase: Phase1, + Genesis: inspectTestRef("phase1/genesis.bin", 8), + Records: []ChainRecord{ + {OutputPayload: inspectTestRef("phase1/contribution-0001.bin", 8)}, + {OutputPayload: inspectTestRef("phase1/contribution-0002.bin", 8)}, + }, + } + missing := missingChainArtifacts(root, chain) + if len(missing) != 2 { + t.Fatalf("missing = %v, want wrong-size and absent entries", missing) + } + if !strings.Contains(missing[0], "contribution-0001.bin") || !strings.Contains(missing[0], "size 3") { + t.Fatalf("wrong-size entry = %q", missing[0]) + } + if missing[1] != "phase1/contribution-0002.bin" { + t.Fatalf("absent entry = %q", missing[1]) + } +} + +func TestMissingChainArtifactsRejectsEscapingNames(t *testing.T) { + t.Parallel() + chain := Chain{ + Phase: Phase2, + Records: []ChainRecord{{OutputPayload: inspectTestRef("../outside.bin", 8)}}, + } + missing := missingChainArtifacts(t.TempDir(), chain) + if len(missing) != 1 || !strings.Contains(missing[0], "unresolvable") { + t.Fatalf("missing = %v, want one unresolvable entry", missing) + } +} + +func TestInspectCeremonyRequiresTranscriptRoot(t *testing.T) { + t.Parallel() + _, err := InspectCeremony(InspectCeremonyOptions{ + Trust: TrustPaths{ + DefinitionPath: "ceremony.json", + DefinitionSignaturePath: "ceremony.sig", + CoordinatorPublicKeyPath: "coordinator.hex", + }, + }) + if err == nil { + t.Fatal("inspect accepted an empty transcript root") + } +} diff --git a/internal/mpcceremony/inspection.go b/internal/mpcceremony/inspection.go new file mode 100644 index 0000000..d936b52 --- /dev/null +++ b/internal/mpcceremony/inspection.go @@ -0,0 +1,142 @@ +package mpcceremony + +import ( + "bytes" + "errors" + "fmt" + "strings" + + "proof-tool/internal/keybundle" +) + +// ParticipantSigningKeyMatch is the immutable public result of matching an +// existing participant signing key to the authenticated ceremony roster. +type ParticipantSigningKeyMatch struct { + ParticipantID string + KeyID string + PublicKeyFingerprint string + Phase1Position *uint8 + Phase2Position *uint8 +} + +// InspectParticipantSigningKey loads an existing Ed25519 private key with the +// same hardened rules used by contribution commands, derives only its public +// key, and matches that public key to exactly one roster participant. It never +// signs or writes anything. +func InspectParticipantSigningKey( + definition CeremonyDefinition, + privateKeyPath string, +) (ParticipantSigningKeyMatch, error) { + if err := definition.Validate(); err != nil { + return ParticipantSigningKeyMatch{}, err + } + privateKey, publicKey, err := keybundle.LoadExistingPrivateKey(privateKeyPath) + if err != nil { + return ParticipantSigningKeyMatch{}, err + } + defer clear(privateKey) + + matches := make([]Identity, 0, 1) + for _, participant := range definition.Roster { + expected, err := identityPublicKey(participant.Identity) + if err != nil { + return ParticipantSigningKeyMatch{}, err + } + if bytes.Equal(publicKey, expected) { + matches = append(matches, participant.Identity) + } + } + if len(matches) > 1 { + return ParticipantSigningKeyMatch{}, errors.New("participant signing key matches more than one roster identity") + } + if len(matches) == 0 { + for _, identity := range nonParticipantCeremonyIdentities(definition) { + expected, err := identityPublicKey(identity) + if err != nil { + return ParticipantSigningKeyMatch{}, err + } + if bytes.Equal(publicKey, expected) { + return ParticipantSigningKeyMatch{}, fmt.Errorf( + "signing key matches non-participant ceremony identity %q", + identity.ID, + ) + } + } + return ParticipantSigningKeyMatch{}, errors.New("signing key does not match any participant in the authenticated roster") + } + + identity := matches[0] + return ParticipantSigningKeyMatch{ + ParticipantID: identity.ID, + KeyID: identity.KeyID, + PublicKeyFingerprint: identity.PublicKeyFingerprint, + Phase1Position: participantSchedulePosition(definition.Phase1Policy.Participants, identity.ID), + Phase2Position: participantSchedulePosition(definition.Phase2Policy.Participants, identity.ID), + }, nil +} + +func participantSchedulePosition(schedule []string, participantID string) *uint8 { + for index, id := range schedule { + if id == participantID { + position := uint8(index + 1) + return &position + } + } + return nil +} + +func nonParticipantCeremonyIdentities(definition CeremonyDefinition) []Identity { + identities := make([]Identity, 0, 2+len(definition.Auditors)) + identities = append(identities, definition.Coordinator, definition.ReleaseSigner) + identities = append(identities, definition.Auditors...) + return identities +} + +// LoadSignedCloseExact authenticates an exact coordinator-signed closure, +// validates its definition-level binding, and returns the safe transcript name +// derived from the same root used to constrain both input paths. +func LoadSignedCloseExact( + trusted *TrustedCeremony, + transcriptRoot, closePath, signaturePath string, +) (AuthenticatedCloseEvidence, string, error) { + if err := validateTrustedCeremony(trusted); err != nil { + return AuthenticatedCloseEvidence{}, "", err + } + if strings.TrimSpace(transcriptRoot) == "" || strings.TrimSpace(closePath) == "" || + strings.TrimSpace(signaturePath) == "" { + return AuthenticatedCloseEvidence{}, "", errors.New("transcript root, closure, and closure signature paths are required") + } + closeName, err := logicalPathWithin(transcriptRoot, closePath) + if err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("closure path: %w", err) + } + if _, err := logicalPathWithin(transcriptRoot, signaturePath); err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("closure signature path: %w", err) + } + closeBytes, err := readRegularBounded(closePath, maxSignedRecordBytes) + if err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("load signed closure: %w", err) + } + signatureBytes, err := readRegularBounded(signaturePath, maxSignedRecordBytes) + if err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("load signed closure: %w", err) + } + var closeRecord CloseRecord + if err := VerifySignedRecord( + closeBytes, + signatureBytes, + &closeRecord, + trusted.Definition.Coordinator.KeyID, + trusted.CoordinatorPublicKey, + ); err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("load signed closure: %w", err) + } + if err := validatePublicWitnessCloseBinding(trusted.Definition, closeRecord); err != nil { + return AuthenticatedCloseEvidence{}, "", fmt.Errorf("closure against definition: %w", err) + } + return AuthenticatedCloseEvidence{ + Record: closeRecord, + RecordBytes: closeBytes, + SignatureBytes: signatureBytes, + }, closeName, nil +} diff --git a/internal/mpcceremony/inspection_test.go b/internal/mpcceremony/inspection_test.go new file mode 100644 index 0000000..3dd1bef --- /dev/null +++ b/internal/mpcceremony/inspection_test.go @@ -0,0 +1,241 @@ +package mpcceremony + +import ( + "bytes" + "crypto/ed25519" + "encoding/hex" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestInspectParticipantSigningKeyMatchesRosterAndSchedule(t *testing.T) { + definition := adversarialDefinition(t) + keyPath := writeInspectionPrivateKey(t, adversarialPrivateKey(0x12)) + match, err := InspectParticipantSigningKey(definition, keyPath) + if err != nil { + t.Fatal(err) + } + identity := definition.Roster[1].Identity + if match.ParticipantID != identity.ID || match.KeyID != identity.KeyID || + match.PublicKeyFingerprint != identity.PublicKeyFingerprint { + t.Fatalf("participant match = %#v", match) + } + if match.Phase1Position == nil || *match.Phase1Position != 2 || + match.Phase2Position == nil || *match.Phase2Position != 2 { + t.Fatalf("participant positions = phase1 %v, phase2 %v", match.Phase1Position, match.Phase2Position) + } + if position := participantSchedulePosition([]string{"participant-01"}, identity.ID); position != nil { + t.Fatalf("absent participant position = %d, want nil", *position) + } +} + +func TestInspectParticipantSigningKeyRejectsUnknownMalformedAndNonParticipants(t *testing.T) { + definition := adversarialDefinition(t) + tests := []struct { + name string + data []byte + }{ + {name: "unknown", data: privateKeyHex(adversarialPrivateKey(0xee))}, + {name: "malformed", data: []byte("not-hex\n")}, + {name: "coordinator", data: privateKeyHex(adversarialPrivateKey(0x01))}, + {name: "release signer", data: privateKeyHex(adversarialPrivateKey(0x02))}, + {name: "auditor", data: privateKeyHex(adversarialPrivateKey(0x03))}, + } + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "identity.private.hex") + if err := os.WriteFile(path, test.data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := InspectParticipantSigningKey(definition, path); err == nil { + t.Fatal("non-participant or malformed key unexpectedly accepted") + } + }) + } +} + +func TestVerifyEnrollmentProofOfPossessionSupportsWitnessAndMirror(t *testing.T) { + definition := adversarialDefinition(t) + definitionBytes, err := MarshalCanonical(definition) + if err != nil { + t.Fatal(err) + } + for _, test := range []struct { + role EnrollmentRole + id string + fill byte + }{ + {role: EnrollmentPublicWitness, id: "public-witness-01", fill: 0x91}, + {role: EnrollmentMirrorOperator, id: "mirror-operator-01", fill: 0xa1}, + } { + t.Run(string(test.role), func(t *testing.T) { + record, recordBytes, signatureBytes := signedExternalEnrollment( + t, definition, definitionBytes, test.role, test.id, test.fill, + ) + verified, err := VerifyEnrollmentProofOfPossession( + definition, definitionBytes, recordBytes, signatureBytes, + ) + if err != nil { + t.Fatal(err) + } + if verified.Identity != record.Identity || verified.Role != test.role { + t.Fatalf("verified enrollment = %#v", verified) + } + + altered := record + altered.EnrolledAt = "2026-07-23T12:00:02Z" + alteredBytes, err := MarshalCanonical(altered) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyEnrollmentProofOfPossession( + definition, definitionBytes, alteredBytes, signatureBytes, + ); err == nil { + t.Fatal("altered enrollment unexpectedly accepted") + } + + var signature DetachedSignature + if err := UnmarshalCanonical(signatureBytes, &signature); err != nil { + t.Fatal(err) + } + signature.SignatureHex = strings.Repeat("00", ed25519.SignatureSize) + alteredSignature, err := MarshalCanonical(signature) + if err != nil { + t.Fatal(err) + } + if _, err := VerifyEnrollmentProofOfPossession( + definition, definitionBytes, recordBytes, alteredSignature, + ); err == nil { + t.Fatal("altered enrollment signature unexpectedly accepted") + } + }) + } +} + +func TestPreparePublicWitnessReceiptEnforcesRoleIdentityAndObservationTiming(t *testing.T) { + definition := adversarialDefinition(t) + definitionBytes, _ := MarshalCanonical(definition) + witness, _, _ := signedExternalEnrollment( + t, definition, definitionBytes, EnrollmentPublicWitness, "public-witness-01", 0x91, + ) + mirror, _, _ := signedExternalEnrollment( + t, definition, definitionBytes, EnrollmentMirrorOperator, "mirror-operator-01", 0xa1, + ) + round := uint64(40_000_000) + roundTime, err := QuicknetRoundTime(round) + if err != nil { + t.Fatal(err) + } + closeRecord := operationalClose(t, definition, Phase1, round, roundTime.Add(-25*time.Hour)) + closeBytes, err := MarshalCanonical(closeRecord) + if err != nil { + t.Fatal(err) + } + location := "https://independent.example/phase1/closure.json" + observedAt := roundTime.Add(-24 * time.Hour).Format(time.RFC3339) + receipt, canonical, err := PreparePublicWitnessReceipt( + definition, + closeRecord, + closeBytes, + witness, + "phase1/closure/record.json", + location, + observedAt, + ) + if err != nil { + t.Fatal(err) + } + if receipt.Witness != witness.Identity || receipt.PublicationLocationSHA != taggedSHA256([]byte(location)) { + t.Fatalf("prepared receipt = %#v", receipt) + } + if bytes.Contains(canonical, []byte(location)) { + t.Fatal("canonical receipt exposed cleartext publication location") + } + + if _, _, err := PreparePublicWitnessReceipt( + definition, closeRecord, closeBytes, mirror, "phase1/closure/record.json", location, observedAt, + ); err == nil { + t.Fatal("mirror enrollment unexpectedly accepted for public-witness receipt") + } + + overlap := witness + overlap.Identity = definition.Coordinator + if _, _, err := PreparePublicWitnessReceipt( + definition, closeRecord, closeBytes, overlap, "phase1/closure/record.json", location, observedAt, + ); err == nil { + t.Fatal("witness enrollment overlapping the coordinator unexpectedly accepted") + } + + for _, test := range []struct { + name string + observedAt time.Time + }{ + {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)}, + } { + t.Run(test.name, func(t *testing.T) { + if _, _, err := PreparePublicWitnessReceipt( + definition, + closeRecord, + closeBytes, + witness, + "phase1/closure/record.json", + location, + test.observedAt.Format(time.RFC3339), + ); err == nil { + t.Fatal("invalid observation time unexpectedly accepted") + } + }) + } +} + +func writeInspectionPrivateKey(t *testing.T, key ed25519.PrivateKey) string { + t.Helper() + path := filepath.Join(t.TempDir(), "participant.private.hex") + if err := os.WriteFile(path, privateKeyHex(key), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +func privateKeyHex(key ed25519.PrivateKey) []byte { + return []byte(hex.EncodeToString(key.Seed()) + "\n") +} + +func signedExternalEnrollment( + t *testing.T, + definition CeremonyDefinition, + definitionBytes []byte, + role EnrollmentRole, + id string, + fill byte, +) (EnrollmentRecord, []byte, []byte) { + t.Helper() + privateKey := adversarialPrivateKey(fill) + identity, err := NewIdentity(id, "Test "+id, id+"-key", privateKey.Public().(ed25519.PublicKey)) + if err != nil { + t.Fatal(err) + } + record, err := NewEnrollmentRecord( + definition, + definitionBytes, + identity, + role, + 1, + ArtifactRef{Name: "disclosures/" + id + ".json", Digest: NewDigest([]byte("independent " + id))}, + "2026-07-23T12:00:01Z", + ) + if err != nil { + t.Fatal(err) + } + recordBytes, signatureBytes, err := SignRecord(record, identity.KeyID, privateKey) + if err != nil { + t.Fatal(err) + } + return record, recordBytes, signatureBytes +} diff --git a/internal/mpcceremony/integration_test.go b/internal/mpcceremony/integration_test.go index fee5979..85702ac 100644 --- a/internal/mpcceremony/integration_test.go +++ b/internal/mpcceremony/integration_test.go @@ -599,7 +599,7 @@ func TestSignedFileWorkflowRejectsReusedPhase1RoundBeforePublicationAndReplays(t t.Fatalf("atomic closure member %q is absent or unsafe: %v", path, err) } } - retriedClose, err := publishReplayedPhaseClose(closeOptions, trusted, loaded.phase1Chain, func() time.Time { + retriedClose, err := publishReplayedPhaseClose(closeOptions, trusted, loaded.phase1Chain, nil, func() time.Time { panic("completed closure retry must not consult the clock") }) if err != nil { diff --git a/internal/mpcceremony/mirror_receipt_prepare_test.go b/internal/mpcceremony/mirror_receipt_prepare_test.go new file mode 100644 index 0000000..34f7f52 --- /dev/null +++ b/internal/mpcceremony/mirror_receipt_prepare_test.go @@ -0,0 +1,139 @@ +package mpcceremony + +import ( + "encoding/json" + "reflect" + "slices" + "strings" + "testing" + "time" +) + +func TestPrepareImmutableMirrorReceiptAuthenticatesDraftChainAndEnrollment(t *testing.T) { + fixture := newOperationalBundleFixture(t) + definitionBytes, err := MarshalCanonical(fixture.definition) + if err != nil { + t.Fatal(err) + } + head := fixture.bundle.Phase1.AcceptedHeads[0] + chainBytes, err := verifyArtifactBytes(fixture.root, head.AcceptedChainPrefix.Record, maxSignedRecordBytes) + if err != nil { + t.Fatal(err) + } + var chain Chain + if err := UnmarshalCanonical(chainBytes, &chain); err != nil { + t.Fatal(err) + } + + var enrollment EnrollmentRecord + var enrollmentBytes, enrollmentSignatureBytes []byte + for _, pair := range fixture.bundle.Enrollments { + recordBytes, err := verifyArtifactBytes(fixture.root, pair.Record, maxSignedRecordBytes) + if err != nil { + t.Fatal(err) + } + var candidate EnrollmentRecord + if err := UnmarshalCanonical(recordBytes, &candidate); err != nil { + t.Fatal(err) + } + if candidate.Role != EnrollmentMirrorOperator { + continue + } + signatureBytes, err := verifyArtifactBytes(fixture.root, pair.Signature, maxSignedRecordBytes) + if err != nil { + t.Fatal(err) + } + enrollment, err = VerifyEnrollmentProofOfPossession( + fixture.definition, definitionBytes, recordBytes, signatureBytes, + ) + if err != nil { + t.Fatal(err) + } + enrollmentBytes, enrollmentSignatureBytes = recordBytes, signatureBytes + break + } + if enrollment.Role != EnrollmentMirrorOperator { + t.Fatal("fixture has no mirror enrollment") + } + + files, err := MirrorReceiptFiles(chain.Records[0], head.AcceptedChainPrefix) + if err != nil { + t.Fatal(err) + } + acceptedAt, _ := time.Parse(time.RFC3339Nano, chain.Records[0].AcceptedAt) + draft := MirrorReceiptDraft{ + CeremonyID: fixture.definition.CeremonyID, + Phase: Phase1, + Index: 1, + AcceptedHeadID: chain.Records[0].RecordID, + Files: append([]ArtifactRef(nil), files...), + StorageLocationSHA256: taggedSHA256([]byte("immutable://mirror-operator-01")), + StoredAt: acceptedAt.Add(time.Minute).Format(time.RFC3339), + } + // Relay intentionally has only a SHA-256 transport digest for the two + // coordinator-signed prefix files. Preparation recomputes their full + // ceremony digests from the exact authenticated bytes. + for index := range draft.Files { + if draft.Files[index].Name == head.AcceptedChainPrefix.Record.Name || + draft.Files[index].Name == head.AcceptedChainPrefix.Signature.Name { + draft.Files[index].Digest.Blake2b256 = "" + } + } + prettyDraft, err := json.MarshalIndent(draft, "", " ") + if err != nil { + t.Fatal(err) + } + parsed, err := ParseMirrorReceiptDraft(prettyDraft) + if err != nil { + t.Fatal(err) + } + receipt, canonical, err := PrepareImmutableMirrorReceipt( + fixture.definition, chain, head.AcceptedChainPrefix, parsed, enrollment, + ) + if err != nil { + t.Fatal(err) + } + if receipt.Mirror != enrollment.Identity || !slices.Equal(receipt.Files, files) { + t.Fatal("prepared receipt did not derive mirror identity and exact files") + } + for _, file := range receipt.Files { + if file.Digest.Blake2b256 == "" { + t.Fatalf("canonical receipt retained missing BLAKE2b digest for %q", file.Name) + } + } + var decoded ImmutableMirrorReceipt + if err := UnmarshalCanonical(canonical, &decoded); err != nil { + t.Fatalf("prepared bytes are not canonical: %v", err) + } + if !reflect.DeepEqual(decoded, receipt) { + t.Fatal("canonical receipt differs from prepared receipt") + } + + tampered := parsed + tampered.Files = append([]ArtifactRef(nil), parsed.Files...) + tampered.Files[0].Digest = NewDigest([]byte("substituted mirror bytes")) + if _, _, err := PrepareImmutableMirrorReceipt( + fixture.definition, chain, head.AcceptedChainPrefix, tampered, enrollment, + ); err == nil { + t.Fatal("draft with substituted artifact unexpectedly accepted") + } + + if _, err := VerifyEnrollmentProofOfPossession( + fixture.definition, + definitionBytes, + enrollmentBytes, + append([]byte(nil), enrollmentSignatureBytes[:len(enrollmentSignatureBytes)-1]...), + ); err == nil { + t.Fatal("truncated mirror enrollment signature unexpectedly accepted") + } +} + +func TestParseMirrorReceiptDraftRejectsUnknownAndDuplicateFields(t *testing.T) { + base := `{"ceremony_id":"sha256:` + strings.Repeat("11", 32) + `","phase":"phase1","index":1,"accepted_head_id":"sha256:` + strings.Repeat("22", 32) + `","files":[{"name":"phase1/file","digest":{"sha256":"` + strings.Repeat("33", 32) + `","blake2b256":"` + strings.Repeat("44", 32) + `","size":1}}],"storage_location_sha256":"sha256:` + strings.Repeat("55", 32) + `","stored_at":"2026-08-18T00:00:00Z"}` + if _, err := ParseMirrorReceiptDraft([]byte(strings.Replace(base, `"phase":"phase1"`, `"phase":"phase1","phase":"phase1"`, 1))); err == nil { + t.Fatal("duplicate draft field unexpectedly accepted") + } + if _, err := ParseMirrorReceiptDraft([]byte(strings.TrimSuffix(base, "}") + `,"mirror":{}}`)); err == nil { + t.Fatal("operator-supplied mirror identity unexpectedly accepted") + } +} diff --git a/internal/mpcceremony/model.go b/internal/mpcceremony/model.go index 3a21165..8f1d261 100644 --- a/internal/mpcceremony/model.go +++ b/internal/mpcceremony/model.go @@ -11,8 +11,10 @@ import ( "path" "strings" "time" + "unicode" "unicode/utf8" + "filippo.io/edwards25519" "golang.org/x/crypto/blake2b" ) @@ -31,12 +33,17 @@ 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" GnarkCryptoVersion = "v0.20.1" DrandVersion = "v2.1.6" - ProductionGoVersion = "go1.26.5" + ProductionGoVersion = "go1.26.6" ProductionGOOS = "linux" ProductionGOARCH = "amd64" ProductionGOAMD64 = "v1" @@ -55,6 +62,12 @@ const ( ModeProduction = "production" MaxParticipants = 20 + // MaxAuditors bounds enrolled auditors. The final transcript stores audit + // reports in an artifact list capped at MaxParticipants entries, so the + // bound must be enforced at enrollment too: without it a ceremony could + // enroll more auditors than the transcript can record and discover that + // only at release, after every audit had already been performed. + MaxAuditors = MaxParticipants ) type Phase string @@ -130,11 +143,8 @@ func (i Identity) Validate() error { if err := validateID("identity id", i.ID); err != nil { return err } - if strings.TrimSpace(i.DisplayName) == "" || i.DisplayName != strings.TrimSpace(i.DisplayName) { - return errors.New("identity display_name must be non-empty and trimmed") - } - if !utf8.ValidString(i.DisplayName) { - return errors.New("identity display_name must be valid UTF-8") + if err := validateDisplayName(i.DisplayName); err != nil { + return fmt.Errorf("identity display_name: %w", err) } if err := validateID("identity key_id", i.KeyID); err != nil { return err @@ -143,6 +153,9 @@ func (i Identity) Validate() error { if err != nil { return fmt.Errorf("identity ed25519_public_key_hex: %w", err) } + if err := validateEd25519PublicKey(pub); err != nil { + return fmt.Errorf("identity ed25519_public_key_hex: %w", err) + } want := taggedSHA256(pub) if i.PublicKeyFingerprint != want { return fmt.Errorf("identity public_key_fingerprint %q, want %q", i.PublicKeyFingerprint, want) @@ -212,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) @@ -500,6 +525,34 @@ func scanJSONValue(decoder *json.Decoder) error { return nil } +// validateEd25519PublicKey rejects the byte strings that decode without error +// but are unusable as a ceremony identity. +// +// Ed25519 verification computes [-k]A + [S]B and compares it to R. When A is a +// small-order point that equation collapses: the signature R = identity, S = 0 +// then verifies against every message, so anyone can forge signatures for that +// identity without holding a private key. Enrolling such a key in the roster +// therefore voids every signature-based control for that participant, auditor, +// witness, coordinator, or release signer. +// +// Non-canonical encodings are rejected separately. Identity uniqueness across +// the definition is enforced on public_key_fingerprint, which is a hash of +// these exact bytes, so two encodings of one point would otherwise present as +// two distinct identities. +func validateEd25519PublicKey(pub []byte) error { + point, err := new(edwards25519.Point).SetBytes(pub) + if err != nil { + return fmt.Errorf("not a valid Ed25519 curve point: %w", err) + } + if !bytes.Equal(point.Bytes(), pub) { + return errors.New("Ed25519 public key is not canonically encoded") + } + if new(edwards25519.Point).MultByCofactor(point).Equal(edwards25519.NewIdentityPoint()) == 1 { + return errors.New("Ed25519 public key has small order; signatures under it are forgeable") + } + return nil +} + func validateTaggedHex(value, prefix string, bytes int) error { if !strings.HasPrefix(value, prefix) { return fmt.Errorf("must start with %q", prefix) @@ -547,6 +600,82 @@ func validateArtifactName(value string) error { if strings.Contains(value, "\\") || strings.HasPrefix(value, "/") || path.Clean(value) != value || value == "." { return fmt.Errorf("artifact name %q must be a clean relative logical path", value) } + if err := rejectDeceptiveRunes(value); err != nil { + return fmt.Errorf("artifact name %q %w", value, err) + } + for segment := range strings.SplitSeq(value, "/") { + if segment != strings.TrimSpace(segment) { + return fmt.Errorf("artifact name %q has untrimmed whitespace in a path segment", value) + } + } + return nil +} + +// maxDisplayNameBytes bounds a human-readable label. It is generous for a name +// plus an affiliation and small enough that a roster stays readable; without a +// cap a single identity can inflate the signed definition and every log line +// that mentions it. +const maxDisplayNameBytes = 256 + +// validateDisplayName checks a human-readable label that is never used for a +// decision but is read by people reviewing a transcript. +// +// The ceremony's audit and release steps depend on humans reading these +// records, so a label must render as the bytes that were signed. Length and +// UTF-8 validity are not enough for that; see rejectDeceptiveRunes. +func validateDisplayName(value string) error { + if value == "" || len(value) > maxDisplayNameBytes { + return fmt.Errorf("must contain 1 to %d bytes", maxDisplayNameBytes) + } + if !utf8.ValidString(value) { + return errors.New("must be valid UTF-8") + } + if value != strings.TrimSpace(value) { + return errors.New("must be trimmed") + } + if strings.TrimSpace(value) == "" { + return errors.New("must not be blank") + } + return rejectDeceptiveRunes(value) +} + +// rejectDeceptiveRunes rejects characters that make a string render as +// something other than the bytes that were signed. +// +// Three classes, all invisible: +// +// - Control characters (Unicode Cc). ANSI escape sequences are terminal +// commands rather than text, so a value printed to a terminal can move the +// cursor and repaint what was already written. +// - Bidirectional formatting (U+202A-U+202E, U+2066-U+2069, U+200E, U+200F). +// These force rendering direction, so bytes stored as U+202E followed by +// "ecila" display as "alice". This is the Trojan Source technique, +// CVE-2021-42574. +// - Zero-width space (U+200B), which renders as nothing, so two values that +// differ in bytes can be indistinguishable on screen. +// +// unicode.IsControl is not sufficient on its own: it reports category Cc only, +// while every bidi and zero-width character above is category Cf. +// +// The bidi and zero-width sets are listed explicitly rather than rejecting all +// of category Cf, because U+200C (ZWNJ) is required for correct Persian and +// Indic text and U+200D (ZWJ) joins emoji sequences. Banning the whole category +// would make legitimate names unwritable. +func rejectDeceptiveRunes(value string) error { + for _, r := range value { + switch { + case unicode.IsControl(r): + return fmt.Errorf("contains control character %U", r) + // Written as escapes on purpose: these characters are invisible, and + // two of them would reorder this source file in an editor. + case r >= '\u202A' && r <= '\u202E', + r >= '\u2066' && r <= '\u2069', + r == '\u200E', r == '\u200F': + return fmt.Errorf("contains bidirectional formatting character %U", r) + case r == '\u200B': + return fmt.Errorf("contains zero-width character %U", r) + } + } return nil } diff --git a/internal/mpcceremony/operational.go b/internal/mpcceremony/operational.go index ab8080d..53f171c 100644 --- a/internal/mpcceremony/operational.go +++ b/internal/mpcceremony/operational.go @@ -350,6 +350,80 @@ type ImmutableMirrorReceipt struct { StoredAt string `json:"stored_at"` } +// MirrorReceiptDraft is the human-reviewable, unsigned input produced by a +// mirror after it stores an authenticated accepted-head prefix. It deliberately +// omits the schema and mirror identity: the ceremony derives both rather than +// trusting operator-authored draft fields. +type MirrorReceiptDraft struct { + CeremonyID string `json:"ceremony_id"` + Phase Phase `json:"phase"` + Index uint8 `json:"index"` + AcceptedHeadID string `json:"accepted_head_id"` + Files []ArtifactRef `json:"files"` + StorageLocationSHA256 string `json:"storage_location_sha256"` + StoredAt string `json:"stored_at"` +} + +func (d MirrorReceiptDraft) Validate() error { + if err := validateOperationalScope(d.CeremonyID, d.Phase, d.Index, d.AcceptedHeadID); err != nil { + return err + } + if err := validateMirrorDraftArtifactSet(d.Files); err != nil { + return err + } + if err := validateTaggedHex(d.StorageLocationSHA256, "sha256:", sha256.Size); err != nil { + return fmt.Errorf("storage_location_sha256: %w", err) + } + return validateTimestamp("stored_at", d.StoredAt) +} + +func validateMirrorDraftArtifactSet(artifacts []ArtifactRef) error { + if len(artifacts) == 0 || len(artifacts) > 128 { + return errors.New("files must contain between 1 and 128 artifacts") + } + previous := "" + for index, artifact := range artifacts { + if err := validateArtifactName(artifact.Name); err != nil { + return fmt.Errorf("files %d: %w", index, err) + } + if index > 0 && artifact.Name <= previous { + return errors.New("files must be ordered by unique artifact name") + } + if err := validateTaggedHex(artifact.Digest.SHA256, "sha256:", sha256.Size); err != nil { + return fmt.Errorf("files %d artifact %q sha256: %w", index, artifact.Name, err) + } + if artifact.Digest.Blake2b256 != "" { + if err := validateTaggedHex(artifact.Digest.Blake2b256, "blake2b256:", 32); err != nil { + return fmt.Errorf("files %d artifact %q blake2b256: %w", index, artifact.Name, err) + } + } + if artifact.Digest.Size <= 0 { + return fmt.Errorf("files %d artifact %q size must be positive", index, artifact.Name) + } + previous = artifact.Name + } + return nil +} + +// ParseMirrorReceiptDraft accepts ordinary JSON for operator review while +// still rejecting duplicate/unknown fields and trailing input. Canonical byte +// encoding is produced only after every draft field has been recomputed. +func ParseMirrorReceiptDraft(data []byte) (MirrorReceiptDraft, error) { + if err := rejectDuplicateKeysAndTrailing(data); err != nil { + return MirrorReceiptDraft{}, err + } + decoder := json.NewDecoder(bytes.NewReader(data)) + decoder.DisallowUnknownFields() + var draft MirrorReceiptDraft + if err := decoder.Decode(&draft); err != nil { + return MirrorReceiptDraft{}, fmt.Errorf("decode mirror receipt draft: %w", err) + } + if err := draft.Validate(); err != nil { + return MirrorReceiptDraft{}, err + } + return draft, nil +} + func (r ImmutableMirrorReceipt) Validate() error { if r.Schema != ImmutableMirrorReceiptSchema { return fmt.Errorf("mirror receipt schema %q, want %q", r.Schema, ImmutableMirrorReceiptSchema) @@ -789,10 +863,7 @@ func ValidatePublicWitnessReceipt( closeBytes []byte, receipt PublicWitnessReceipt, ) error { - if err := definition.Validate(); err != nil { - return err - } - if err := close.Validate(); err != nil { + if err := validatePublicWitnessCloseBinding(definition, close); err != nil { return err } if err := receipt.Validate(); err != nil { @@ -831,6 +902,54 @@ func ValidatePublicWitnessReceipt( return nil } +func validatePublicWitnessCloseBinding(definition CeremonyDefinition, close CloseRecord) error { + if err := definition.Validate(); err != nil { + return err + } + if err := close.Validate(); err != nil { + return err + } + if close.CeremonyID != definition.CeremonyID { + return errors.New("closure ceremony does not match authenticated definition") + } + if close.CoordinatorID != definition.Coordinator.ID || + close.CoordinatorKeyID != definition.Coordinator.KeyID { + return errors.New("closure coordinator does not match authenticated definition") + } + if close.BeaconProvider != definition.BeaconPolicy.Provider || + close.BeaconNetwork != definition.BeaconPolicy.Network { + return errors.New("closure beacon does not match authenticated definition policy") + } + createdAt, _ := time.Parse(time.RFC3339Nano, definition.CreatedAt) + closedAt, _ := time.Parse(time.RFC3339Nano, close.ClosedAt) + roundTime, err := QuicknetRoundTime(close.BeaconRound) + if err != nil { + return err + } + if !closedAt.After(createdAt) { + return errors.New("closure must be created after the ceremony definition") + } + if !roundTime.After(closedAt) { + return errors.New("closure beacon round was not in the future when the phase closed") + } + minimumLead := time.Duration(definition.BeaconPolicy.MinimumWitnessLeadSeconds) * time.Second + if roundTime.Sub(closedAt) < minimumLead { + return fmt.Errorf( + "closure beacon round lead %s is below signed minimum %s", + roundTime.Sub(closedAt), + minimumLead, + ) + } + if requiredLead := requiredCloseLead(definition); roundTime.Sub(closedAt) < requiredLead { + return fmt.Errorf( + "closure beacon round lead %s does not reserve the production witness observation window: need %s", + roundTime.Sub(closedAt), + requiredLead, + ) + } + return nil +} + func ValidateMultiRelayBeaconEvidence( definition CeremonyDefinition, close CloseRecord, diff --git a/internal/mpcceremony/operational_builder.go b/internal/mpcceremony/operational_builder.go index db7cb59..c802116 100644 --- a/internal/mpcceremony/operational_builder.go +++ b/internal/mpcceremony/operational_builder.go @@ -1,8 +1,12 @@ package mpcceremony import ( + "bytes" "encoding/json" + "errors" "fmt" + "slices" + "time" ) // NewEnrollmentRecord derives the frozen definition and full-roster bindings; @@ -137,6 +141,156 @@ func NewImmutableMirrorReceipt( return record, record.Validate() } +// VerifyEnrollmentProofOfPossession authenticates an exact canonical +// enrollment record and its detached signature against the frozen ceremony. +func VerifyEnrollmentProofOfPossession( + definition CeremonyDefinition, + definitionBytes, recordBytes, signatureBytes []byte, +) (EnrollmentRecord, error) { + if err := definition.Validate(); err != nil { + return EnrollmentRecord{}, err + } + canonicalDefinition, err := MarshalCanonical(definition) + if err != nil { + return EnrollmentRecord{}, err + } + if !bytes.Equal(definitionBytes, canonicalDefinition) { + return EnrollmentRecord{}, errors.New("definition bytes are not the exact canonical definition") + } + var record EnrollmentRecord + if err := UnmarshalCanonical(recordBytes, &record); err != nil { + return EnrollmentRecord{}, err + } + signer, err := VerifyOperationalRecordBinding(definition, definitionBytes, &record) + if err != nil { + return EnrollmentRecord{}, err + } + publicKey, err := identityPublicKey(signer) + if err != nil { + return EnrollmentRecord{}, err + } + var signature DetachedSignature + if err := UnmarshalCanonical(signatureBytes, &signature); err != nil { + return EnrollmentRecord{}, err + } + if err := VerifyExact(recordBytes, signature, signer.KeyID, publicKey); err != nil { + return EnrollmentRecord{}, err + } + return record, nil +} + +// MirrorReceiptFiles derives the only artifact set valid for a receipt over an +// accepted chain prefix. +func MirrorReceiptFiles(record ChainRecord, chainPrefix SignedArtifactRefs) ([]ArtifactRef, error) { + if err := record.Validate(); err != nil { + return nil, err + } + if err := chainPrefix.Validate(); err != nil { + return nil, err + } + files := []ArtifactRef{ + record.Attestation, + record.AttestationSignature, + record.Erasure, + record.ErasureSignature, + record.OutputPayload, + record.Verification, + chainPrefix.Record, + chainPrefix.Signature, + } + slices.SortFunc(files, compareArtifactRefName) + if err := validateArtifactSet("files", files); err != nil { + return nil, err + } + return files, nil +} + +// PrepareImmutableMirrorReceipt replaces every authenticated draft field with +// values derived from the ceremony, exact chain prefix, and signed mirror +// enrollment. Any disagreement is rejected before canonical bytes exist. +func PrepareImmutableMirrorReceipt( + definition CeremonyDefinition, + chain Chain, + chainPrefix SignedArtifactRefs, + draft MirrorReceiptDraft, + enrollment EnrollmentRecord, +) (ImmutableMirrorReceipt, []byte, error) { + if err := definition.Validate(); err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if err := chain.ValidateAgainstDefinition(definition); err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if err := draft.Validate(); err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + definitionBytes, err := MarshalCanonical(definition) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, &enrollment); err != nil { + return ImmutableMirrorReceipt{}, nil, fmt.Errorf("mirror enrollment binding: %w", err) + } + if enrollment.Role != EnrollmentMirrorOperator { + return ImmutableMirrorReceipt{}, nil, errors.New("receipt enrollment role must be mirror-operator") + } + if int(draft.Index) != len(chain.Records) { + return ImmutableMirrorReceipt{}, nil, fmt.Errorf( + "receipt index %d must equal authenticated chain prefix length %d", + draft.Index, len(chain.Records), + ) + } + record := chain.Records[len(chain.Records)-1] + acceptedAt, _ := time.Parse(time.RFC3339Nano, record.AcceptedAt) + storedAt, _ := time.Parse(time.RFC3339Nano, draft.StoredAt) + if !storedAt.After(acceptedAt) { + return ImmutableMirrorReceipt{}, nil, errors.New("mirror receipt stored_at must be after accepted head time") + } + expectedFiles, err := MirrorReceiptFiles(record, chainPrefix) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + if draft.CeremonyID != definition.CeremonyID || draft.CeremonyID != chain.CeremonyID || + draft.Phase != chain.Phase || draft.AcceptedHeadID != record.RecordID || + !mirrorDraftFilesMatch(draft.Files, expectedFiles) { + return ImmutableMirrorReceipt{}, nil, errors.New("mirror receipt draft does not bind the exact authenticated accepted-head prefix") + } + receipt, err := NewImmutableMirrorReceipt( + definition.CeremonyID, + chain.Phase, + draft.Index, + record.RecordID, + expectedFiles, + enrollment.Identity, + draft.StorageLocationSHA256, + draft.StoredAt, + ) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + canonical, err := MarshalCanonical(receipt) + if err != nil { + return ImmutableMirrorReceipt{}, nil, err + } + return receipt, canonical, nil +} + +func mirrorDraftFilesMatch(draft, expected []ArtifactRef) bool { + if len(draft) != len(expected) { + return false + } + for index := range draft { + if draft[index].Name != expected[index].Name || + draft[index].Digest.SHA256 != expected[index].Digest.SHA256 || + draft[index].Digest.Size != expected[index].Digest.Size || + (draft[index].Digest.Blake2b256 != "" && + draft[index].Digest.Blake2b256 != expected[index].Digest.Blake2b256) { + return false + } + } + return true +} + func NewPublicWitnessReceipt( definition CeremonyDefinition, close CloseRecord, @@ -167,6 +321,45 @@ func NewPublicWitnessReceipt( return record, nil } +// PreparePublicWitnessReceipt derives canonical receipt bytes from an +// authenticated public-witness enrollment and a human's publication claim. +// It hashes the location before record construction and never signs anything. +func PreparePublicWitnessReceipt( + definition CeremonyDefinition, + close CloseRecord, + closeBytes []byte, + enrollment EnrollmentRecord, + closureName, publicationLocation, observedAt string, +) (PublicWitnessReceipt, []byte, error) { + definitionBytes, err := MarshalCanonical(definition) + if err != nil { + return PublicWitnessReceipt{}, nil, err + } + if _, err := VerifyOperationalRecordBinding(definition, definitionBytes, &enrollment); err != nil { + return PublicWitnessReceipt{}, nil, fmt.Errorf("witness enrollment binding: %w", err) + } + if enrollment.Role != EnrollmentPublicWitness { + return PublicWitnessReceipt{}, nil, errors.New("receipt enrollment role must be public-witness") + } + receipt, err := NewPublicWitnessReceipt( + definition, + close, + closeBytes, + enrollment.Identity, + closureName, + taggedSHA256([]byte(publicationLocation)), + observedAt, + ) + if err != nil { + return PublicWitnessReceipt{}, nil, err + } + canonical, err := MarshalCanonical(receipt) + if err != nil { + return PublicWitnessReceipt{}, nil, err + } + return receipt, canonical, nil +} + func NewMultiRelayBeaconEvidence( definition CeremonyDefinition, close CloseRecord, diff --git a/internal/mpcceremony/operational_bundle.go b/internal/mpcceremony/operational_bundle.go index 49e2a31..590ef72 100644 --- a/internal/mpcceremony/operational_bundle.go +++ b/internal/mpcceremony/operational_bundle.go @@ -708,25 +708,11 @@ func verifyEnrollmentEvidence( if err != nil { return nil, nil, fmt.Errorf("enrollment %d signature: %w", index, err) } - var record EnrollmentRecord - if err := UnmarshalCanonical(recordBytes, &record); err != nil { - return nil, nil, fmt.Errorf("enrollment %d: %w", index, err) - } - signer, err := VerifyOperationalRecordBinding(definition, definitionBytes, &record) + record, err := VerifyEnrollmentProofOfPossession(definition, definitionBytes, recordBytes, signatureBytes) if err != nil { - return nil, nil, fmt.Errorf("enrollment %d binding: %w", index, err) - } - publicKey, err := identityPublicKey(signer) - if err != nil { - return nil, nil, err - } - var signature DetachedSignature - if err := UnmarshalCanonical(signatureBytes, &signature); err != nil { - return nil, nil, err - } - if err := VerifyExact(recordBytes, signature, signer.KeyID, publicKey); err != nil { return nil, nil, fmt.Errorf("enrollment %d proof of possession: %w", index, err) } + signer := record.Identity if _, err := verifyArtifactBytes(root, record.IndependenceDisclosure, 1<<20); err != nil { return nil, nil, fmt.Errorf("enrollment %d independence disclosure: %w", index, err) } @@ -1042,14 +1028,10 @@ func verifyAcceptedHeadEvidence( mirrorIDs := make(map[string]struct{}, len(evidence.MirrorReceipts)) mirrorKeys := make(map[string]struct{}, len(evidence.MirrorReceipts)) - expectedMirrorFiles := append([]ArtifactRef(nil), expectedReturnFiles...) - expectedMirrorFiles = append( - expectedMirrorFiles, - record.Verification, - evidence.AcceptedChainPrefix.Record, - evidence.AcceptedChainPrefix.Signature, - ) - slices.SortFunc(expectedMirrorFiles, compareArtifactRefName) + expectedMirrorFiles, err := MirrorReceiptFiles(record, evidence.AcceptedChainPrefix) + if err != nil { + return nil, fmt.Errorf("accepted head %d mirror files: %w", index+1, err) + } for mirrorIndex, pair := range evidence.MirrorReceipts { mirrorAny, mirrorRefs, err := verifyOperationalPair( definition, diff --git a/internal/mpcceremony/operational_bundle_test.go b/internal/mpcceremony/operational_bundle_test.go index 8e95b89..6501450 100644 --- a/internal/mpcceremony/operational_bundle_test.go +++ b/internal/mpcceremony/operational_bundle_test.go @@ -397,6 +397,11 @@ func newOperationalBundleFixture(t *testing.T) operationalBundleFixture { round42Time, _ := QuicknetRoundTime(42) definition.Mode = ModeRehearsal definition.CreatedAt = round42Time.Add(-30 * time.Hour).Format(time.RFC3339) + // Keep this downstream evidence fixture small. Canonical production circuit + // identity is covered by definition_test.go; rehearsal mode may bind these + // synthetic R1CS bytes so the exact release-tree checks can exercise real + // files without embedding the 129 MB production constraint system. + definition.Circuit.R1CS.Digest = NewDigest([]byte("r1cs")) definition.Circuit.Constraints = 1_789_750 definition.Circuit.DomainSize = 1 << 21 definition.Phase1Policy.Minimum = 1 diff --git a/internal/mpcceremony/phase1.go b/internal/mpcceremony/phase1.go index fc84bcf..9e1082d 100644 --- a/internal/mpcceremony/phase1.go +++ b/internal/mpcceremony/phase1.go @@ -116,6 +116,12 @@ func SealPhase1Loaded( // sealReplayedPhase1Head consumes a freshly replayed head. gnark's Seal // intentionally mutates that head, so callers must not retain or reuse it. +// +// The returned commons also aliases the head: Seal returns p.parameters by +// value, and those slice headers point at the head's backing arrays rather than +// at copies. Mutating or re-sealing the head after this call therefore corrupts +// the commons that was already handed to the caller. Callers must drop their +// reference to the head at the point of the seal. func sealReplayedPhase1Head( domainN uint64, beaconChallenge []byte, diff --git a/internal/mpcceremony/phase2.go b/internal/mpcceremony/phase2.go index 476b7c5..1546c81 100644 --- a/internal/mpcceremony/phase2.go +++ b/internal/mpcceremony/phase2.go @@ -198,6 +198,10 @@ func SealPhase2Loaded( return nil, nil, err } + // Seal does not copy the evaluations: the returned proving and verifying + // keys retain evals.G1.CKK and evals.G1.VKK directly. The evaluations must + // therefore stay per-call and must never be cached or shared between + // seals, or two key sets would alias one set of commitment arrays. var provingKey, verifyingKey any if err := runGnarkMutation("seal Phase 2", func() { provingKey, verifyingKey = head.Seal(commons, evaluations, append([]byte(nil), beaconChallenge...)) @@ -270,6 +274,10 @@ func replayPhase2State( } previous = next } + // The returned evaluations are freshly derived for this replay and must be + // treated that way. Seal retains their CKK and VKK slices in the keys it + // produces, so a cached or reused Phase2Evaluations would leave two key + // sets aliasing one set of commitment arrays. return previous, &evaluations, nil } 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") + } +} diff --git a/internal/mpcceremony/software_test.go b/internal/mpcceremony/software_test.go index f5ba025..d6f81cd 100644 --- a/internal/mpcceremony/software_test.go +++ b/internal/mpcceremony/software_test.go @@ -267,7 +267,7 @@ func TestRunningSoftwareBindingRejectsUnverifiableBuilds(t *testing.T) { { name: "unapproved Go version", mutate: func(info *debug.BuildInfo) { - info.GoVersion = "go1.26.6" + info.GoVersion = "go1.26.5" }, }, { diff --git a/internal/mpcceremony/testdata/workflowhelper/main.go b/internal/mpcceremony/testdata/workflowhelper/main.go index 90c5f63..84367af 100644 --- a/internal/mpcceremony/testdata/workflowhelper/main.go +++ b/internal/mpcceremony/testdata/workflowhelper/main.go @@ -10,6 +10,7 @@ import ( "os" "os/exec" "path/filepath" + "slices" "strings" "time" @@ -445,6 +446,10 @@ func run(outputRoot, operationalEvidenceHelper string) error { if err != nil { return fmt.Errorf("record Phase 1 beacon: %w", err) } + // The seal replays the whole phase and is the longest operation in a K=21 + // ceremony, so its progress callback is wired here and asserted below: a + // silent multi-hour command is the defect this reports against. + sealProgress := 0 phase1Seal, err := mpcceremony.SealPhase1Files(mpcceremony.SealPhase1FilesOptions{ Trust: trust, Circuit: circuit, @@ -455,11 +460,24 @@ func run(outputRoot, operationalEvidenceHelper string) error { BeaconSignaturePath: phase1Beacon.SignaturePath, CoordinatorPrivateKeyPath: coordinatorKeyPath, OutputDir: filepath.Join(ceremonyRoot, "phase1", "sealed"), + Progress: func(phase mpcceremony.Phase, index, total int) { + if phase != mpcceremony.Phase1 || index < 1 || index > total { + panic(fmt.Sprintf("seal progress reported %s %d/%d", phase, index, total)) + } + sealProgress++ + }, }) if err != nil { return fmt.Errorf("seal Phase 1: %w", err) } + if sealProgress == 0 { + return errors.New("Phase 1 seal replayed without reporting progress") + } + // Phase 2 initialization reports stages rather than contributions, because + // its cost is one monolithic transform rather than a per-contribution + // replay. Assert every stage arrives, in order. + var phase2Stages []int phase2Initialized, err := mpcceremony.InitializePhase2Files(mpcceremony.InitPhase2FilesOptions{ Trust: trust, Circuit: circuit, @@ -467,11 +485,20 @@ func run(outputRoot, operationalEvidenceHelper string) error { Phase1SealPath: phase1Seal.SealPath, Phase1SealSignaturePath: phase1Seal.SignaturePath, CoordinatorPrivateKeyPath: coordinatorKeyPath, - OutputDir: filepath.Join(ceremonyRoot, "phase2"), + Progress: func(stage string, index, total int) { + if stage == "" || index < 1 || index > total { + panic(fmt.Sprintf("phase 2 stage %q reported %d/%d", stage, index, total)) + } + phase2Stages = append(phase2Stages, index) + }, + OutputDir: filepath.Join(ceremonyRoot, "phase2"), }) if err != nil { return fmt.Errorf("initialize Phase 2: %w", err) } + if !slices.Equal(phase2Stages, []int{1, 2, 3}) { + return fmt.Errorf("phase 2 initialization reported stages %v, want [1 2 3]", phase2Stages) + } phase2Paths := mpcceremony.PhaseTranscriptPaths{ RootDir: ceremonyRoot, ChainPath: phase2Initialized.ChainPath, @@ -590,6 +617,48 @@ func run(outputRoot, operationalEvidenceHelper string) error { Phase2BeaconPath: phase2Beacon.BeaconPath, Phase2BeaconSignaturePath: phase2Beacon.SignaturePath, } + // Both phases are complete, closed, beaconed, and phase 1 is sealed. + // Exercise the read-only inspection at both depths from inside the same + // binary that ran init, so the running-software gate verifies a real + // executable identity exactly as it does for every other command. + for _, full := range []bool{false, true} { + inspection, err := mpcceremony.InspectCeremony(mpcceremony.InspectCeremonyOptions{ + Trust: mpcceremony.TrustPaths{ + DefinitionPath: initialized.DefinitionPath, + DefinitionSignaturePath: initialized.DefinitionSignaturePath, + CoordinatorPublicKeyPath: trustedCoordinatorPath, + }, + TranscriptRoot: ceremonyRoot, + Full: full, + }) + if err != nil { + return fmt.Errorf("inspect ceremony (full=%v): %w", full, err) + } + if inspection.CeremonyID != initialized.Definition.CeremonyID { + return fmt.Errorf("inspect ceremony id %q, want %q", inspection.CeremonyID, initialized.Definition.CeremonyID) + } + if len(inspection.Phases) != 2 { + return fmt.Errorf("inspect reported %d phases, want 2", len(inspection.Phases)) + } + for _, phase := range inspection.Phases { + if !phase.Started || !phase.ContributionsComplete || !phase.Closed || !phase.BeaconRecorded { + return fmt.Errorf("inspect %s state = %+v, want complete/closed/beaconed", phase.Phase, phase) + } + if phase.AcceptedCount != 2 || phase.ScheduledTotal != 2 { + return fmt.Errorf("inspect %s accepted %d/%d, want 2/2", phase.Phase, phase.AcceptedCount, phase.ScheduledTotal) + } + if phase.NextParticipantID != "" || phase.NextIndex != 0 { + return fmt.Errorf("inspect %s still schedules %q at %d", phase.Phase, phase.NextParticipantID, phase.NextIndex) + } + if len(phase.MissingArtifacts) != 0 { + return fmt.Errorf("inspect %s reports missing artifacts: %v", phase.Phase, phase.MissingArtifacts) + } + if wantSealed := phase.Phase == mpcceremony.Phase1; phase.Sealed != wantSealed { + return fmt.Errorf("inspect %s sealed = %v, want %v", phase.Phase, phase.Sealed, wantSealed) + } + } + } + preliminaryDir := filepath.Join(outputRoot, "preliminary") if _, err := mpcceremony.PrepareFinalization(mpcceremony.PrepareFinalizationOptions{ Replay: replay, diff --git a/internal/mpcceremony/workflow.go b/internal/mpcceremony/workflow.go index 72e071a..5159eea 100644 --- a/internal/mpcceremony/workflow.go +++ b/internal/mpcceremony/workflow.go @@ -75,6 +75,9 @@ func (p InitParticipants) Validate() error { if len(p.Auditors) < 2 { return errors.New("at least two independent auditors are required") } + if len(p.Auditors) > MaxAuditors { + return fmt.Errorf("auditors exceed maximum %d recordable in the final transcript", MaxAuditors) + } if len(p.Roster) == 0 || len(p.Roster) > MaxParticipants { return fmt.Errorf("roster must contain between 1 and %d participants", MaxParticipants) } @@ -372,41 +375,106 @@ func InitializeCeremonyFiles(options InitFilesOptions) (result InitFilesResult, return result, nil } +// ReplayProgress reports how far a chain replay has advanced. It is called once +// per accepted contribution, immediately before that contribution is read, with +// a one-based index and the total the replay will process. +// +// This package deliberately has no logger: it handles signing keys and secret +// contribution state, so having no output path at all is stronger than having a +// careful one. A callback keeps that property. The values carry no secret +// material — a phase, an index and a count — and rendering is entirely the +// caller's business. The CLI writes them to stderr, never stdout, which is +// reserved for the result contract. +// +// A K=21 close replays for hours. Without progress an operator cannot tell +// running from hung, and cannot measure how long a close takes on their +// hardware. That measurement is what makes it possible to choose a beacon round +// far enough ahead; misjudging it is what caused the 2026-07-24 closure-timing +// incident. +type ReplayProgress func(phase Phase, index, total int) + +// StageProgress reports entry into a named stage of a long operation, with a +// one-based index and the total number of stages. +// +// ReplayProgress counts accepted contributions, which suits any command whose +// cost is dominated by replaying a chain. Phase 2 initialization has no +// contributions to count: it loads and verifies the sealed phase 1 commons, +// transforms them into circuit-specific parameters over the whole 2^21 domain, +// and publishes the result. That transform is a single monolithic computation +// running for hours, so a per-contribution callback reports nothing at all. +// +// This is coarser than an index into work completed, and deliberately so. The +// expensive stage lives inside gnark and exposes no progress of its own, so the +// honest signal is which stage is running rather than a fabricated percentage. +// It still separates running from hung, and it names the stage an operator is +// waiting on. Like ReplayProgress it carries no secret material and does not +// print: rendering is the caller's business. +type StageProgress func(stage string, index, total int) + type PhaseTranscriptPaths struct { RootDir string ChainPath string ChainSignaturePath string + + // Progress is optional. When nil the replay is silent, which is the + // behaviour every existing caller gets. + Progress ReplayProgress } // LoadSignedChain verifies the exact coordinator-signed chain at paths. func LoadSignedChain(trusted *TrustedCeremony, paths PhaseTranscriptPaths) (Chain, error) { + chain, _, err := LoadSignedChainExact(trusted, paths) + return chain, err +} + +// LoadSignedChainExact verifies a coordinator-signed chain and returns artifact +// references computed from the same exact bytes that were authenticated. +func LoadSignedChainExact(trusted *TrustedCeremony, paths PhaseTranscriptPaths) (Chain, SignedArtifactRefs, error) { if err := validateTrustedCeremony(trusted); err != nil { - return Chain{}, err + return Chain{}, SignedArtifactRefs{}, err } if strings.TrimSpace(paths.RootDir) == "" || strings.TrimSpace(paths.ChainPath) == "" || strings.TrimSpace(paths.ChainSignaturePath) == "" { - return Chain{}, errors.New("transcript root, chain, and chain signature paths are required") + return Chain{}, SignedArtifactRefs{}, errors.New("transcript root, chain, and chain signature paths are required") + } + chainName, err := logicalPathWithin(paths.RootDir, paths.ChainPath) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("chain path: %w", err) } - if _, err := logicalPathWithin(paths.RootDir, paths.ChainPath); err != nil { - return Chain{}, fmt.Errorf("chain path: %w", err) + signatureName, err := logicalPathWithin(paths.RootDir, paths.ChainSignaturePath) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("chain signature path: %w", err) } - if _, err := logicalPathWithin(paths.RootDir, paths.ChainSignaturePath); err != nil { - return Chain{}, fmt.Errorf("chain signature path: %w", err) + chainBytes, err := readRegularBounded(paths.ChainPath, maxSignedRecordBytes) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("load signed chain: %w", err) + } + signatureBytes, err := readRegularBounded(paths.ChainSignaturePath, maxSignedRecordBytes) + if err != nil { + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("load signed chain: %w", err) } var chain Chain - if err := loadCoordinatorSignedRecord( - trusted, - paths.ChainPath, - paths.ChainSignaturePath, + if err := VerifySignedRecord( + chainBytes, + signatureBytes, &chain, + trusted.Definition.Coordinator.KeyID, + trusted.CoordinatorPublicKey, ); err != nil { - return Chain{}, fmt.Errorf("load signed chain: %w", err) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("load signed chain: %w", err) } if err := chain.ValidateAgainstDefinition(trusted.Definition); err != nil { - return Chain{}, fmt.Errorf("chain against definition: %w", err) + return Chain{}, SignedArtifactRefs{}, fmt.Errorf("chain against definition: %w", err) } - return chain, nil + refs := SignedArtifactRefs{ + Record: ArtifactRef{Name: chainName, Digest: NewDigest(chainBytes)}, + Signature: ArtifactRef{Name: signatureName, Digest: NewDigest(signatureBytes)}, + } + if err := refs.Validate(); err != nil { + return Chain{}, SignedArtifactRefs{}, err + } + return chain, refs, nil } // LoadReplayPhase1Files strictly reads all accepted evidence and replays every @@ -465,7 +533,7 @@ func loadReplayPhase1FilesState( if err != nil { return Chain{}, nil, err } - loader := phase1FileLoader(paths.RootDir, chain, circuit.Binding.DomainSize) + loader := phase1FileLoader(paths.RootDir, chain, circuit.Binding.DomainSize, paths.Progress) head, err := replayPhase1State(circuit.Binding.DomainSize, len(chain.Records), loader) if err != nil { return Chain{}, nil, err @@ -553,7 +621,7 @@ func LoadReplayPhase2Files( if err != nil { return Chain{}, err } - loader := phase2FileLoader(paths.RootDir, chain, contributionPhase2Shape(circuit.Binding.Phase2Shape)) + loader := phase2FileLoader(paths.RootDir, chain, contributionPhase2Shape(circuit.Binding.Phase2Shape), paths.Progress) if err := ReplayPhase2Loaded(circuit, commons, len(chain.Records), loader); err != nil { return Chain{}, err } @@ -625,7 +693,7 @@ func CreateContributionCandidate(options ContributionFilesOptions) (result Contr contribution, contributeErr := ContributePhase1Loaded( options.Circuit.Binding.DomainSize, len(chain.Records), - phase1FileLoader(options.Transcript.RootDir, chain, options.Circuit.Binding.DomainSize), + phase1FileLoader(options.Transcript.RootDir, chain, options.Circuit.Binding.DomainSize, options.Transcript.Progress), ) if contributeErr != nil { return nil, contributeErr @@ -649,7 +717,7 @@ func CreateContributionCandidate(options ContributionFilesOptions) (result Contr options.Circuit, commons, len(chain.Records), - phase2FileLoader(options.Transcript.RootDir, chain, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape)), + phase2FileLoader(options.Transcript.RootDir, chain, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape), options.Transcript.Progress), ) if contributeErr != nil { return nil, contributeErr @@ -1011,15 +1079,24 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result options.Transcript.RootDir, chain, options.Circuit.Binding.DomainSize, + nil, )(index - 2) } if err != nil { return result, fmt.Errorf("load authenticated Phase 1 head: %w", err) } + // gnark's Verify writes next.Challenge, and this candidate is retained + // and re-serialized into the authoritative transcript below. Hand the + // verifier a throwaway clone so no gnark call ever holds the archived + // pointer. + verifyCandidate := new(gnarkmpc.Phase1) + if err := streamClone(candidate, verifyCandidate); err != nil { + return result, fmt.Errorf("clone Phase 1 candidate for verification: %w", err) + } if err := verifyPhase1Transition( options.Circuit.Binding.DomainSize, previous, - candidate, + verifyCandidate, ); err != nil { return result, fmt.Errorf("verify candidate Phase 1 transition: %w", err) } @@ -1038,12 +1115,19 @@ func VerifyAndAcceptContribution(options AcceptContributionFilesOptions) (result options.Transcript.RootDir, chain, contributionPhase2Shape(options.Circuit.Binding.Phase2Shape), + nil, )(index - 2) } if err != nil { return result, fmt.Errorf("load authenticated Phase 2 head: %w", err) } - if err := verifyPhase2Transition(previous, candidate); err != nil { + // Same hazard as Phase 1: Verify writes next.Challenge and this + // candidate is retained for the transcript, so verify a clone. + verifyCandidate := new(gnarkmpc.Phase2) + if err := streamClone(candidate, verifyCandidate); err != nil { + return result, fmt.Errorf("clone Phase 2 candidate for verification: %w", err) + } + if err := verifyPhase2Transition(previous, verifyCandidate); err != nil { return result, fmt.Errorf("verify candidate Phase 2 transition: %w", err) } } @@ -1350,7 +1434,25 @@ type ClosePhaseFilesOptions struct { Phase1SealPath string Phase1SealSignaturePath string CoordinatorPrivateKeyPath string - BeaconRound uint64 + // BeaconRound names the future round explicitly. Exactly one of this and + // BeaconRoundLeadSeconds must be set. + BeaconRound uint64 + // BeaconRoundLeadSeconds derives the round instead of naming it, using the + // clock sampled after the replay. + // + // A close replays the entire accepted phase before it stamps closed_at, and + // at domain 2^21 that takes hours. An explicit round therefore forces the + // coordinator to predict their own replay duration: name a round too near + // and the whole replay is discarded for naming a round that was no longer + // in the future. That is what caused the 2026-07-24 closure-timing + // incident. + // + // Deriving here is not weaker. The round is not published, signed, or + // observable until the closure record is written at the end of this + // function, so choosing it before or after the replay is indistinguishable + // to every observer, and under either ordering the round is still in the + // future and its randomness does not yet exist. + BeaconRoundLeadSeconds uint32 } type ClosePhaseFilesResult struct { @@ -1374,6 +1476,10 @@ func closePhaseFiles( if now == nil { return ClosePhaseFilesResult{}, errors.New("closure clock is required") } + if (options.BeaconRound == 0) == (options.BeaconRoundLeadSeconds == 0) { + return ClosePhaseFilesResult{}, errors.New( + "exactly one of beacon round and beacon round lead is required") + } trusted, err := loadOperationalCeremony(options.Trust) if err != nil { return ClosePhaseFilesResult{}, err @@ -1392,21 +1498,27 @@ func closePhaseFilesAuthenticated( } var chain Chain var err error + // Retained past the switch so a derived phase 2 round can be checked for + // reuse of the phase 1 round, which an explicit round is checked for here. + var phase1Close *CloseRecord switch options.Phase { case Phase1: chain, err = LoadReplayPhase1Files(trusted, options.Circuit, options.Transcript) case Phase2: var commons *gnarkmpc.SrsCommons var phase1Seal SealRecord - var phase1Close CloseRecord - commons, phase1Seal, phase1Close, err = loadPhase1CommonsForPhase2( + var loadedClose CloseRecord + commons, phase1Seal, loadedClose, err = loadPhase1CommonsForPhase2( trusted, options.Circuit, options.Transcript.RootDir, options.Phase1SealPath, options.Phase1SealSignaturePath, ) - if err == nil && options.BeaconRound == phase1Close.BeaconRound { + if err == nil { + phase1Close = &loadedClose + } + if err == nil && options.BeaconRound != 0 && options.BeaconRound == loadedClose.BeaconRound { err = fmt.Errorf( "phase2 beacon round %d reuses the authenticated phase1 beacon round; a distinct round is required", options.BeaconRound, @@ -1421,13 +1533,16 @@ func closePhaseFilesAuthenticated( if err != nil { return result, err } - return publishReplayedPhaseClose(options, trusted, chain, now) + return publishReplayedPhaseClose(options, trusted, chain, phase1Close, now) } func publishReplayedPhaseClose( options ClosePhaseFilesOptions, trusted *TrustedCeremony, chain Chain, + // phase1Close is non-nil only for a phase 2 close, and is what a derived + // round is checked against for round reuse. + phase1Close *CloseRecord, now func() time.Time, ) (ClosePhaseFilesResult, error) { var result ClosePhaseFilesResult @@ -1462,7 +1577,10 @@ func publishReplayedPhaseClose( ); err != nil { return result, fmt.Errorf("load existing atomic phase closure: %w", err) } - if existing.BeaconRound != options.BeaconRound { + // Only an explicitly requested round can disagree with what was + // published; a derived round has no operator intent to contradict, and + // the existing record is authenticated and revalidated below either way. + if options.BeaconRound != 0 && existing.BeaconRound != options.BeaconRound { return result, fmt.Errorf( "existing phase closure commits beacon round %d, not requested round %d", existing.BeaconRound, @@ -1485,14 +1603,38 @@ func publishReplayedPhaseClose( return result, fmt.Errorf("inspect phase closure destination: %w", statErr) } - roundTime, err := QuicknetRoundTime(options.BeaconRound) - if err != nil { - return result, err - } closedAt := now().UTC() if closedAt.IsZero() { return result, errors.New("closure clock returned the zero time") } + // Sampled before the round is resolved, so a derived round is measured from + // the moment the replay actually finished. + beaconRound := options.BeaconRound + if beaconRound == 0 { + // The publication guard re-checks the lead against a second clock + // sample and demands the signed minimum plus a safety margin, so derive + // past that rather than past the bare minimum. + lead := time.Duration(options.BeaconRoundLeadSeconds) * time.Second + if required := requiredCloseLead(trusted.Definition); lead < required { + lead = required + } + beaconRound, err = FirstQuicknetRoundAfter( + closedAt.Add(lead + closePublicationSafetyMargin), + ) + if err != nil { + return result, fmt.Errorf("derive beacon round from close time: %w", err) + } + if options.Phase == Phase2 && phase1Close != nil && beaconRound == phase1Close.BeaconRound { + return result, fmt.Errorf( + "derived phase2 beacon round %d reuses the authenticated phase1 beacon round", + beaconRound, + ) + } + } + roundTime, err := QuicknetRoundTime(beaconRound) + if err != nil { + return result, err + } closeRecord, err := NewCloseRecord(CloseRecord{ CeremonyID: trusted.Definition.CeremonyID, Phase: options.Phase, @@ -1503,7 +1645,7 @@ func publishReplayedPhaseClose( AcceptedParticipants: participants, BeaconProvider: trusted.Definition.BeaconPolicy.Provider, BeaconNetwork: trusted.Definition.BeaconPolicy.Network, - BeaconRound: options.BeaconRound, + BeaconRound: beaconRound, BeaconNotBefore: roundTime.Format(time.RFC3339Nano), ClosedAt: closedAt.Format(time.RFC3339Nano), CoordinatorID: trusted.Definition.Coordinator.ID, @@ -1543,7 +1685,7 @@ func publishReplayedPhaseClose( closedAt, now().UTC(), roundTime, - trusted.Definition.BeaconPolicy.MinimumWitnessLeadSeconds, + trusted.Definition, ) }, ); err != nil { @@ -1557,7 +1699,7 @@ func validateCloseCommitTime( closedAt time.Time, commitTime time.Time, roundTime time.Time, - minimumWitnessLeadSeconds uint32, + definition CeremonyDefinition, ) error { if closedAt.IsZero() { return errors.New("closure clock returned the zero time") @@ -1568,11 +1710,11 @@ func validateCloseCommitTime( if commitTime.Before(closedAt) { return errors.New("closure clock moved backwards before publication") } - minimumLead := time.Duration(minimumWitnessLeadSeconds) * time.Second + minimumLead := requiredCloseLead(definition) requiredLead := minimumLead + closePublicationSafetyMargin if roundTime.Sub(commitTime) < requiredLead { return fmt.Errorf( - "beacon round lead at closure publication %s is below required %s (signed witness lead %s plus publication margin %s)", + "beacon round lead at closure publication %s is below required %s (required witness lead %s plus publication margin %s)", roundTime.Sub(commitTime), requiredLead, minimumLead, @@ -1749,6 +1891,12 @@ type SealPhase1FilesOptions struct { BeaconSignaturePath string CoordinatorPrivateKeyPath string OutputDir string + // Progress is optional and reports the replay this seal performs before it + // applies the beacon contribution. The seal replays the whole phase and + // then does strictly more work than a close, so at domain 2^21 it is the + // longest operation in the ceremony; without this it is also the only long + // one that is completely silent. + Progress ReplayProgress } type SealPhase1FilesResult struct { @@ -1784,6 +1932,7 @@ func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResu RootDir: options.TranscriptRoot, ChainPath: chainPath, ChainSignaturePath: DefaultSignaturePath(chainPath), + Progress: options.Progress, } chain, replayedHead, err := loadReplayPhase1FilesState(trusted, options.Circuit, chainPaths) if err != nil { @@ -1902,6 +2051,9 @@ func SealPhase1Files(options SealPhase1FilesOptions) (result SealPhase1FilesResu return result, nil } +// initPhase2StageCount is the number of stages InitializePhase2Files reports. +const initPhase2StageCount = 3 + type InitPhase2FilesOptions struct { Trust TrustPaths Circuit *CompiledCircuit @@ -1910,6 +2062,9 @@ type InitPhase2FilesOptions struct { Phase1SealSignaturePath string CoordinatorPrivateKeyPath string OutputDir string + // Progress is optional and reports stage entry. When nil this runs silent, + // which is the behaviour every existing caller gets. + Progress StageProgress } type InitPhase2FilesResult struct { @@ -1929,6 +2084,14 @@ func InitializePhase2Files(options InitPhase2FilesOptions) (result InitPhase2Fil if err := validateWorkflowCircuit(trusted, options.Circuit); err != nil { return result, err } + // Three stages, of wildly unequal cost. Stage 2 dominates: it transforms the + // commons over the whole domain and is where hours are spent. + stage := func(name string, index int) { + if options.Progress != nil { + options.Progress(name, index, initPhase2StageCount) + } + } + stage("verify sealed phase 1 commons", 1) commons, phase1Seal, _, err := loadPhase1CommonsForPhase2( trusted, options.Circuit, @@ -1943,10 +2106,12 @@ func InitializePhase2Files(options InitPhase2FilesOptions) (result InitPhase2Fil if err != nil { return result, err } + stage("derive circuit-specific phase 2 parameters", 2) initial, shape, err := InitializePhase2(options.Circuit, commons) if err != nil { return result, err } + stage("publish phase 2 genesis", 3) if !equalPhase2Shape(shape, options.Circuit.Binding.Phase2Shape) { return result, errors.New("initialized Phase 2 shape differs from signed circuit binding") } @@ -2827,11 +2992,14 @@ func verifyChainFiles(trusted *TrustedCeremony, root string, chain Chain, basePh return nil } -func phase1FileLoader(root string, chain Chain, domainN uint64) Phase1Loader { +func phase1FileLoader(root string, chain Chain, domainN uint64, progress ReplayProgress) Phase1Loader { return func(index int) (*gnarkmpc.Phase1, error) { if index < 0 || index >= len(chain.Records) { return nil, fmt.Errorf("Phase 1 contribution index %d out of range", index) } + if progress != nil { + progress(Phase1, index+1, len(chain.Records)) + } path, err := resolveArtifactPath(root, chain.Records[index].OutputPayload.Name) if err != nil { return nil, err @@ -2841,11 +3009,14 @@ func phase1FileLoader(root string, chain Chain, domainN uint64) Phase1Loader { } } -func phase2FileLoader(root string, chain Chain, shape Phase2Shape) Phase2Loader { +func phase2FileLoader(root string, chain Chain, shape Phase2Shape, progress ReplayProgress) Phase2Loader { return func(index int) (*gnarkmpc.Phase2, error) { if index < 0 || index >= len(chain.Records) { return nil, fmt.Errorf("Phase 2 contribution index %d out of range", index) } + if progress != nil { + progress(Phase2, index+1, len(chain.Records)) + } path, err := resolveArtifactPath(root, chain.Records[index].OutputPayload.Name) if err != nil { return nil, err diff --git a/internal/mpcceremony/workflow_test.go b/internal/mpcceremony/workflow_test.go index 47def26..e7283e8 100644 --- a/internal/mpcceremony/workflow_test.go +++ b/internal/mpcceremony/workflow_test.go @@ -4,12 +4,64 @@ import ( "bytes" "crypto/ed25519" "encoding/hex" + "encoding/json" "os" "path/filepath" "strings" "testing" ) +func TestLoadSignedDefinitionRejectsAuthenticatedNoncanonicalProductionCircuit(t *testing.T) { + definition := adversarialDefinition(t) + definition.Circuit.Constraints = 1791413 + definition.CeremonyID = "" + // Simulate a coordinator that signs a self-consistent but unapproved + // definition. canonicalHash is used directly because the public constructor + // correctly refuses to create this definition. + ceremonyID, err := canonicalHash("proof-tool/mpc-ceremony/root/v1", definition) + if err != nil { + t.Fatal(err) + } + definition.CeremonyID = ceremonyID + definitionBytes, err := json.Marshal(definition) + if err != nil { + t.Fatal(err) + } + privateKey := adversarialPrivateKey(0x01) + signature, err := SignExact(definitionBytes, definition.Coordinator.KeyID, privateKey) + if err != nil { + t.Fatal(err) + } + signatureBytes, err := MarshalCanonical(signature) + if err != nil { + t.Fatal(err) + } + + dir := t.TempDir() + definitionPath := filepath.Join(dir, "ceremony.json") + signaturePath := filepath.Join(dir, "ceremony.sig") + publicKeyPath := filepath.Join(dir, "coordinator-public-key.hex") + if err := os.WriteFile(definitionPath, definitionBytes, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(signaturePath, signatureBytes, 0o600); err != nil { + t.Fatal(err) + } + publicKey := privateKey.Public().(ed25519.PublicKey) + if err := os.WriteFile(publicKeyPath, []byte(hex.EncodeToString(publicKey)+"\n"), 0o600); err != nil { + t.Fatal(err) + } + + _, err = LoadSignedDefinition(TrustPaths{ + DefinitionPath: definitionPath, + DefinitionSignaturePath: signaturePath, + CoordinatorPublicKeyPath: publicKeyPath, + }) + if err == nil || !strings.Contains(err.Error(), "canonical") { + t.Fatalf("authenticated noncanonical production circuit error = %v", err) + } +} + func TestResolveArtifactPathRejectsSymlinkComponent(t *testing.T) { root := t.TempDir() outside := t.TempDir() diff --git a/internal/mpcrehearsal/config.go b/internal/mpcrehearsal/config.go new file mode 100644 index 0000000..dfe8754 --- /dev/null +++ b/internal/mpcrehearsal/config.go @@ -0,0 +1,243 @@ +// Package mpcrehearsal creates fresh same-host identities and exact canonical +// inputs for a local MPC ceremony rehearsal. It is deliberately not a +// production enrollment tool: production identities must be generated and +// governed independently by their owners. +package mpcrehearsal + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "runtime" + + "proof-tool/internal/mpcceremony" +) + +const ( + minRehearsalParticipants = 3 + maxRehearsalParticipants = 20 + minRehearsalBeaconLead = 60 +) + +type generatedIdentity struct { + identity mpcceremony.Identity + privateKey ed25519.PrivateKey +} + +func Generate(outDir string, participantCount int, beaconWitnessLead uint32) (err error) { + if participantCount < minRehearsalParticipants || + participantCount > maxRehearsalParticipants { + return fmt.Errorf( + "participants must be between %d and %d", + minRehearsalParticipants, + maxRehearsalParticipants, + ) + } + if beaconWitnessLead < minRehearsalBeaconLead { + return fmt.Errorf( + "beacon witness lead must be at least %d seconds", + minRehearsalBeaconLead, + ) + } + if err := os.Mkdir(outDir, 0o700); err != nil { + return fmt.Errorf("create fresh rehearsal config root: %w", err) + } + removeRoot := true + defer func() { + if err != nil && removeRoot { + _ = os.RemoveAll(outDir) + } + }() + keyDir := filepath.Join(outDir, "keys") + configDir := filepath.Join(outDir, "config") + for _, path := range []string{keyDir, configDir} { + if err := os.Mkdir(path, 0o700); err != nil { + return err + } + } + + newIdentity := func(id, displayName string) (generatedIdentity, error) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + return generatedIdentity{}, err + } + identity, err := mpcceremony.NewIdentity( + id, + displayName, + id+"-key", + publicKey, + ) + if err != nil { + return generatedIdentity{}, err + } + return generatedIdentity{identity: identity, privateKey: privateKey}, nil + } + + coordinator, err := newIdentity("coordinator", "Local Rehearsal Coordinator") + if err != nil { + return err + } + releaseSigner, err := newIdentity("release-signer", "Local Rehearsal Release Signer") + if err != nil { + return err + } + auditor1, err := newIdentity("auditor-01", "Local Rehearsal Auditor 01") + if err != nil { + return err + } + auditor2, err := newIdentity("auditor-02", "Local Rehearsal Auditor 02") + if err != nil { + return err + } + witness1, err := newIdentity("witness-01", "Local Rehearsal Public Witness 01") + if err != nil { + return err + } + witness2, err := newIdentity("witness-02", "Local Rehearsal Public Witness 02") + if err != nil { + return err + } + mirror1, err := newIdentity("mirror-01", "Local Rehearsal Mirror Operator 01") + if err != nil { + return err + } + mirror2, err := newIdentity("mirror-02", "Local Rehearsal Mirror Operator 02") + if err != nil { + return err + } + generated := []generatedIdentity{ + coordinator, + releaseSigner, + auditor1, + auditor2, + witness1, + witness2, + mirror1, + mirror2, + } + participants := make([]mpcceremony.Participant, 0, participantCount) + participantIDs := make([]string, 0, participantCount) + for index := 1; index <= participantCount; index++ { + id := fmt.Sprintf("participant-%02d", index) + participant, err := newIdentity(id, "Local Rehearsal "+id) + if err != nil { + return err + } + generated = append(generated, participant) + participants = append(participants, mpcceremony.Participant{Identity: participant.identity}) + participantIDs = append(participantIDs, id) + } + + for _, item := range generated { + seedPath := filepath.Join(keyDir, item.identity.ID+".ed25519.private.hex") + if err := writeNoReplace( + seedPath, + []byte(hex.EncodeToString(item.privateKey.Seed())+"\n"), + 0o600, + ); err != nil { + return err + } + publicPath := filepath.Join(keyDir, item.identity.ID+".ed25519.public.hex") + if err := writeNoReplace( + publicPath, + []byte(item.identity.Ed25519PublicKeyHex+"\n"), + 0o600, + ); err != nil { + return err + } + } + + enrollment := mpcceremony.InitParticipants{ + Coordinator: coordinator.identity, + ReleaseSigner: releaseSigner.identity, + Auditors: []mpcceremony.Identity{auditor1.identity, auditor2.identity}, + Roster: participants, + } + policy := mpcceremony.InitPolicy{ + Phase1Policy: mpcceremony.PhasePolicy{ + Participants: participantIDs, + Minimum: uint8(participantCount), + }, + Phase2Policy: mpcceremony.PhasePolicy{ + Participants: append([]string(nil), participantIDs...), + Minimum: uint8(participantCount), + }, + BeaconPolicy: mpcceremony.BeaconPolicy{ + Provider: mpcceremony.BeaconProviderDrand, + Network: mpcceremony.BeaconNetworkQuicknet, + ChainHashHex: mpcceremony.BeaconQuicknetChainHash, + PublicKeyHex: mpcceremony.BeaconQuicknetPublicKey, + Scheme: mpcceremony.BeaconQuicknetScheme, + GenesisTimeUnix: mpcceremony.BeaconQuicknetGenesis, + PeriodSeconds: mpcceremony.BeaconQuicknetPeriod, + Extraction: mpcceremony.BeaconExtractionV1, + MinimumChallengeBytes: 32, + MinimumWitnessLeadSeconds: beaconWitnessLead, + FutureRoundRequired: true, + }, + } + environment := mpcceremony.ContributionEnvironment{ + OS: runtime.GOOS, + Architecture: runtime.GOARCH, + EntropySource: "operating-system-csprng", + SwapDisabled: true, + CrashDumpsDisabled: true, + TelemetryDisabled: true, + EphemeralEnvironment: true, + EphemeralDestructionRequired: true, + } + for name, value := range map[string]any{ + "participants.json": enrollment, + "policy.json": policy, + "environment.json": environment, + } { + data, err := mpcceremony.MarshalCanonical(value) + if err != nil { + return err + } + if err := writeNoReplace(filepath.Join(configDir, name), data, 0o600); err != nil { + return err + } + } + if err := writeNoReplace( + filepath.Join(outDir, "participant-count.txt"), + []byte(fmt.Sprintf("%d\n", participantCount)), + 0o600, + ); err != nil { + return err + } + removeRoot = false + return nil +} + +func writeNoReplace(path string, data []byte, mode os.FileMode) error { + if len(data) == 0 { + return errors.New("refusing to write empty rehearsal config") + } + file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) + if err != nil { + return err + } + remove := true + defer func() { + _ = file.Close() + if remove { + _ = os.Remove(path) + } + }() + if _, err := file.Write(data); err != nil { + return err + } + if err := file.Sync(); err != nil { + return err + } + if err := file.Close(); err != nil { + return err + } + remove = false + return nil +} diff --git a/internal/proofassets/pkindex.go b/internal/proofassets/pkindex.go index 06dc711..83a9082 100644 --- a/internal/proofassets/pkindex.go +++ b/internal/proofassets/pkindex.go @@ -158,13 +158,79 @@ func ValidatePKIndex(idx *PKIndex) error { if sec.Len%int64(sec.ElemSize) != 0 { return fmt.Errorf("section %q length %d is not divisible by elem_size %d", name, sec.Len, sec.ElemSize) } - if sec.Offset+sec.Len > idx.FileSize { + // Subtraction keeps a hostile offset+length pair from wrapping int64 + // negative and passing the file boundary check. + if sec.Len > idx.FileSize || sec.Offset > idx.FileSize-sec.Len { return fmt.Errorf("section %q exceeds file size", name) } } return nil } +// ValidatePKIndexAllocations bounds the counter fields that drive memory +// allocation when a proving key is opened: make([]bool, NbWires) and +// make([]pedersen.ProvingKey, NbCommitmentKeys) in KeySource.loadSmallFields, +// and len(wires)-NbInfinityA in the prove path. It is separate from +// ValidatePKIndex because the manifest-derived index carries only section +// geometry (the counters live outside the signed digest); call this only where +// a full index with populated counters is consumed. Each counter is bounded +// against FileSize and Sections — fields the manifest digest does cover — so an +// out-of-range counter is unrepresentable without also changing a signed field. +// +// ValidatePKIndex must have passed first. +func ValidatePKIndexAllocations(idx *PKIndex) error { + if idx == nil { + return fmt.Errorf("index is required") + } + g2b, ok := idx.Sections["G2B"] + if !ok { + return fmt.Errorf("index missing section \"G2B\"") + } + // Layout after G2B: nbWires|NbInfinityA|NbInfinityB (3×8 bytes), then the + // two infinity bitmaps of NbWires bytes each, then the 4-byte commitment + // count. Everything must fit inside FileSize. + const infHeaderLen = 3 * 8 + const countLen = 4 + if idx.NbWires > math.MaxInt64 { + return fmt.Errorf("nb_wires %d is implausibly large", idx.NbWires) + } + if g2b.Len > idx.FileSize || g2b.Offset > idx.FileSize-g2b.Len { + return fmt.Errorf("G2B section exceeds file_size %d", idx.FileSize) + } + infOff := g2b.Offset + g2b.Len + if infOff > idx.FileSize || idx.FileSize-infOff < infHeaderLen+countLen { + return fmt.Errorf("infinity metadata does not fit within file_size %d", idx.FileSize) + } + bitmapBytes := idx.FileSize - infOff - infHeaderLen - countLen + if idx.NbWires > uint64(bitmapBytes/2) { + return fmt.Errorf("nb_wires %d does not fit within file_size %d", idx.NbWires, idx.FileSize) + } + if idx.NbInfinityA > idx.NbWires || idx.NbInfinityB > idx.NbWires { + return fmt.Errorf("nb_infinity (%d, %d) exceeds nb_wires %d", idx.NbInfinityA, idx.NbInfinityB, idx.NbWires) + } + // Each commitment key contributes exactly two sections (Basis, + // BasisExpSigma) on top of the five base sections, so the count is bounded + // by the section map — itself bounded by the parsed input — and every + // referenced section must be present. + if 5+2*uint64(idx.NbCommitmentKeys) != uint64(len(idx.Sections)) { + return fmt.Errorf("nb_commitment_keys %d is inconsistent with %d sections", idx.NbCommitmentKeys, len(idx.Sections)) + } + for i := 0; i < int(idx.NbCommitmentKeys); i++ { + basisName, sigmaName := "Basis", "BasisExpSigma" + if i > 0 { + basisName = fmt.Sprintf("Basis_%d", i) + sigmaName = fmt.Sprintf("BasisExpSigma_%d", i) + } + if _, ok := idx.Sections[basisName]; !ok { + return fmt.Errorf("index missing commitment section %q", basisName) + } + if _, ok := idx.Sections[sigmaName]; !ok { + return fmt.Errorf("index missing commitment section %q", sigmaName) + } + } + return nil +} + func WritePKIndex(path string, idx *PKIndex) error { if err := ValidatePKIndex(idx); err != nil { return err @@ -192,6 +258,9 @@ func ReadPKIndex(path string) (*PKIndex, error) { if err := ValidatePKIndex(&idx); err != nil { return nil, err } + if err := ValidatePKIndexAllocations(&idx); err != nil { + return nil, err + } return &idx, nil } diff --git a/internal/proofassets/pkindex_test.go b/internal/proofassets/pkindex_test.go new file mode 100644 index 0000000..2122fcf --- /dev/null +++ b/internal/proofassets/pkindex_test.go @@ -0,0 +1,77 @@ +package proofassets + +import ( + "math" + "strings" + "testing" +) + +// validAllocIndex returns a PKIndex whose geometry and counters are mutually +// consistent, matching what BuildPKIndex produces for a one-commitment key. +func validAllocIndex() *PKIndex { + const g2bOff = 10_000 + sections := map[string]PKSection{ + "A": {Name: "A", Offset: 100, Len: G1RawBytes, ElemSize: G1RawBytes}, + "B": {Name: "B", Offset: 200, Len: G1RawBytes, ElemSize: G1RawBytes}, + "Z": {Name: "Z", Offset: 300, Len: G1RawBytes, ElemSize: G1RawBytes}, + "K": {Name: "K", Offset: 400, Len: G1RawBytes, ElemSize: G1RawBytes}, + "G2B": {Name: "G2B", Offset: g2bOff, Len: G2RawBytes, ElemSize: G2RawBytes}, + "Basis": {Name: "Basis", Offset: 20_000, Len: G1RawBytes, ElemSize: G1RawBytes}, + "BasisExpSigma": {Name: "BasisExpSigma", Offset: 21_000, Len: G1RawBytes, ElemSize: G1RawBytes}, + } + return &PKIndex{ + Sections: sections, + NbWires: 4, + NbInfinityA: 1, + NbInfinityB: 0, + NbCommitmentKeys: 1, + FileSize: 100_000, + } +} + +func TestValidatePKIndexAllocations(t *testing.T) { + if err := ValidatePKIndex(validAllocIndex()); err != nil { + t.Fatalf("geometry validation failed on valid index: %v", err) + } + if err := ValidatePKIndexAllocations(validAllocIndex()); err != nil { + t.Fatalf("allocation validation failed on valid index: %v", err) + } + + cases := []struct { + name string + mutate func(*PKIndex) + wantSub string + }{ + {"huge commitment count", func(i *PKIndex) { i.NbCommitmentKeys = 0xFFFFFFFF }, "nb_commitment_keys"}, + {"nbWires overflow", func(i *PKIndex) { i.NbWires = math.MaxUint64 }, "implausibly large"}, + {"nbWires arithmetic boundary", func(i *PKIndex) { i.NbWires = math.MaxInt64 / 2 }, "does not fit"}, + {"nbWires exceeds file", func(i *PKIndex) { i.NbWires = 1 << 40 }, "does not fit"}, + {"infinity exceeds wires", func(i *PKIndex) { i.NbInfinityA = 5 }, "exceeds nb_wires"}, + {"missing basis section", func(i *PKIndex) { + i.NbCommitmentKeys = 2 + i.Sections["Basis_1"] = PKSection{Name: "Basis_1", Offset: 30_000, Len: G1RawBytes, ElemSize: G1RawBytes} + // count now claims 2 keys (9 sections needed) but only 8 present: + // the equality check fires before the per-key lookup. + }, "inconsistent"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + idx := validAllocIndex() + tc.mutate(idx) + err := ValidatePKIndexAllocations(idx) + if err == nil || !strings.Contains(err.Error(), tc.wantSub) { + t.Fatalf("want error containing %q, got %v", tc.wantSub, err) + } + }) + } +} + +func TestValidatePKIndexRejectsSectionEndOverflow(t *testing.T) { + idx := validAllocIndex() + section := idx.Sections["G2B"] + section.Offset = math.MaxInt64 - section.Len + 1 + idx.Sections["G2B"] = section + if err := ValidatePKIndex(idx); err == nil || !strings.Contains(err.Error(), "exceeds file size") { + t.Fatalf("expected overflowing section rejection, got %v", err) + } +} diff --git a/internal/prover/constraint_system.go b/internal/prover/constraint_system.go new file mode 100644 index 0000000..d8f6a79 --- /dev/null +++ b/internal/prover/constraint_system.go @@ -0,0 +1,33 @@ +package prover + +import ( + "bytes" + "encoding/binary" + "fmt" + "io" +) + +const gnarkConstraintSystemHeaderBytes = 4 * 8 + +// PreflightConstraintSystemReader validates gnark's declared payload length +// before ReadFrom can allocate from it. The returned reader replays the header +// and then continues from r, so callers can pass it directly to ReadFrom. +// Callers must still cap r itself to maxBytes to bound the bytes transported. +func PreflightConstraintSystemReader(r io.Reader, maxBytes int64) (io.Reader, error) { + if r == nil { + return nil, fmt.Errorf("constraint system reader is required") + } + if maxBytes < gnarkConstraintSystemHeaderBytes { + return nil, fmt.Errorf("constraint system maximum %d is smaller than its %d-byte header", maxBytes, gnarkConstraintSystemHeaderBytes) + } + var header [gnarkConstraintSystemHeaderBytes]byte + if _, err := io.ReadFull(r, header[:]); err != nil { + return nil, fmt.Errorf("read constraint system header: %w", err) + } + declared := binary.LittleEndian.Uint64(header[:8]) + maxPayload := uint64(maxBytes - gnarkConstraintSystemHeaderBytes) + if declared > maxPayload { + return nil, fmt.Errorf("constraint system declares %d payload bytes, exceeds maximum %d", declared, maxPayload) + } + return io.MultiReader(bytes.NewReader(header[:]), r), nil +} diff --git a/internal/prover/constraint_system_test.go b/internal/prover/constraint_system_test.go new file mode 100644 index 0000000..7e7019e --- /dev/null +++ b/internal/prover/constraint_system_test.go @@ -0,0 +1,49 @@ +package prover + +import ( + "bytes" + "encoding/binary" + "io" + "strings" + "testing" +) + +func TestPreflightConstraintSystemReader(t *testing.T) { + header := make([]byte, gnarkConstraintSystemHeaderBytes) + binary.LittleEndian.PutUint64(header[:8], 3) + raw := append(header, 1, 2, 3) + + r, err := PreflightConstraintSystemReader(bytes.NewReader(raw), int64(len(raw))) + if err != nil { + t.Fatal(err) + } + got, err := io.ReadAll(r) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(got, raw) { + t.Fatalf("replayed bytes differ: got %x, want %x", got, raw) + } +} + +func TestPreflightConstraintSystemReaderRejectsDeclaredAllocation(t *testing.T) { + header := make([]byte, gnarkConstraintSystemHeaderBytes) + binary.LittleEndian.PutUint64(header[:8], 1<<30) + body := []byte("body must remain unread") + source := bytes.NewReader(append(header, body...)) + + _, err := PreflightConstraintSystemReader(source, 1024) + if err == nil || !strings.Contains(err.Error(), "declares") { + t.Fatalf("expected declared-size rejection, got %v", err) + } + if source.Len() != len(body) { + t.Fatalf("preflight read %d payload bytes", len(body)-source.Len()) + } +} + +func TestPreflightConstraintSystemReaderRejectsShortHeader(t *testing.T) { + _, err := PreflightConstraintSystemReader(bytes.NewReader(make([]byte, 8)), 1024) + if err == nil || !strings.Contains(err.Error(), "header") { + t.Fatalf("expected short-header rejection, got %v", err) + } +} diff --git a/internal/prover/prover.go b/internal/prover/prover.go index 14d738b..1b33a51 100644 --- a/internal/prover/prover.go +++ b/internal/prover/prover.go @@ -4,6 +4,7 @@ import ( "bytes" "crypto/sha256" "encoding/base64" + "encoding/binary" "encoding/hex" "fmt" "hash" @@ -56,6 +57,40 @@ const ( PokOff = CmtOff + 2*g1Len ) +const ( + // maxProofCommitments bounds the BSB22 commitment slice declared in an + // encoded proof. The ownership circuits use a single commitment; the cap + // is generous so unrelated circuits still decode, while a hostile count is + // rejected before gnark-crypto allocates it. + maxProofCommitments = 16 + + // proofCommitmentCountOffset is the byte offset of the big-endian uint32 + // commitment count in a compressed groth16-BLS12-381 proof: it follows + // Ar(G1) | Bs(G2) | Krs(G1). See the gnark Proof.ReadFrom field order. + proofCommitmentCountOffset = CardanoProofLen // 2*g1Len + g2Len + + // maxEncodedProofBytes bounds the raw proof before decoding. A well-formed + // proof for this family is a few hundred bytes; the cap is a coarse first + // gate so an oversized body is rejected cheaply. + maxEncodedProofBytes = 4096 +) + +func requireCompressedPoint(raw []byte, offset int, name string) error { + if offset < 0 || offset >= len(raw) { + return fmt.Errorf("proof is too short for %s", name) + } + // gnark accepts compressed and uncompressed encodings. The fixed offsets + // below are safe only for the canonical compressed representation emitted + // by MarshalProof. Accept the two compressed sign encodings and compressed + // infinity; reject every uncompressed or reserved metadata mask. + switch raw[offset] & 0xe0 { + case 0x80, 0xa0, 0xc0: + return nil + default: + return fmt.Errorf("proof %s must use canonical compressed encoding", name) + } +} + type OwnershipBundle struct { Dir string Manifest *artifact.KeyManifest @@ -421,6 +456,48 @@ func UnmarshalProof(encoded string) (groth16.Proof, error) { if err != nil { return nil, fmt.Errorf("decode proof: %w", err) } + // Preflight the length-prefixed commitment slice before gnark-crypto's + // decoder reaches it. That decoder does make([]G1Affine, count) directly + // from an attacker-controlled uint32 (gnark-crypto ecc/bls12-381 marshal), + // so an unchecked count is a memory-exhaustion primitive on any caller + // that decodes untrusted proofs (e.g. the verifier HTTP API). Bounding the + // count and requiring the exact encoded length makes the decode allocate + // only what the bytes actually carry. + if len(raw) > maxEncodedProofBytes { + return nil, fmt.Errorf("proof is %d bytes, exceeds maximum %d", len(raw), maxEncodedProofBytes) + } + if len(raw) < proofCommitmentCountOffset+4 { + return nil, fmt.Errorf("proof is %d bytes, too short to be well-formed", len(raw)) + } + if err := requireCompressedPoint(raw, 0, "Ar"); err != nil { + return nil, err + } + if err := requireCompressedPoint(raw, g1Len, "Bs"); err != nil { + return nil, err + } + if err := requireCompressedPoint(raw, g1Len+g2Len, "Krs"); err != nil { + return nil, err + } + nbCommitments := binary.BigEndian.Uint32(raw[proofCommitmentCountOffset : proofCommitmentCountOffset+4]) + if nbCommitments > maxProofCommitments { + return nil, fmt.Errorf("proof declares %d commitments, exceeds maximum %d", nbCommitments, maxProofCommitments) + } + // Ar|Bs|Krs, then the 4-byte count, then nbCommitments compressed G1 + // points, then the compressed G1 proof-of-knowledge. + wantLen := proofCommitmentCountOffset + 4 + int(nbCommitments)*g1Len + g1Len + if len(raw) != wantLen { + return nil, fmt.Errorf("proof is %d bytes, want %d for %d commitments", len(raw), wantLen, nbCommitments) + } + pointOffset := proofCommitmentCountOffset + 4 + for i := uint32(0); i < nbCommitments; i++ { + if err := requireCompressedPoint(raw, pointOffset, fmt.Sprintf("commitment[%d]", i)); err != nil { + return nil, err + } + pointOffset += g1Len + } + if err := requireCompressedPoint(raw, pointOffset, "commitment proof"); err != nil { + return nil, err + } proof := groth16.NewProof(curve) if _, err := proof.ReadFrom(bytes.NewReader(raw)); err != nil { return nil, fmt.Errorf("read proof: %w", err) diff --git a/internal/prover/prover_test.go b/internal/prover/prover_test.go index c2b3d98..2798f48 100644 --- a/internal/prover/prover_test.go +++ b/internal/prover/prover_test.go @@ -3,6 +3,7 @@ package prover import ( "bytes" "encoding/base64" + "encoding/binary" "encoding/hex" "os" "path/filepath" @@ -56,6 +57,62 @@ func TestSmallProofMarshalVerifyAndRejectsWrongPublicInput(t *testing.T) { } } +func TestUnmarshalProofRejectsHostileCommitmentCount(t *testing.T) { + // A proof whose commitment-count prefix is enormous would drive + // make([]G1Affine, count) in gnark-crypto's decoder — a memory-exhaustion + // primitive for any endpoint that decodes untrusted proofs. It must be + // rejected before ReadFrom is ever called. + raw := make([]byte, proofCommitmentCountOffset+4) + raw[0] = 0xc0 + raw[g1Len] = 0xc0 + raw[g1Len+g2Len] = 0xc0 + binary.BigEndian.PutUint32(raw[proofCommitmentCountOffset:], 0xFFFFFFFF) + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(raw)); err == nil || + !strings.Contains(err.Error(), "commitments") { + t.Fatalf("expected commitment-count rejection, got %v", err) + } + + // Oversized body rejected by the coarse gate. + big := make([]byte, maxEncodedProofBytes+1) + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(big)); err == nil || + !strings.Contains(err.Error(), "maximum") { + t.Fatalf("expected oversize rejection, got %v", err) + } + + // Too short to carry the count prefix. + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(make([]byte, 8))); err == nil || + !strings.Contains(err.Error(), "too short") { + t.Fatalf("expected too-short rejection, got %v", err) + } + + // Declared count is in range but the body length does not match it. + mismatch := make([]byte, proofCommitmentCountOffset+4) + mismatch[0] = 0xc0 + mismatch[g1Len] = 0xc0 + mismatch[g1Len+g2Len] = 0xc0 + binary.BigEndian.PutUint32(mismatch[proofCommitmentCountOffset:], 1) + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(mismatch)); err == nil || + !strings.Contains(err.Error(), "want") { + t.Fatalf("expected length-mismatch rejection, got %v", err) + } +} + +func TestUnmarshalProofRejectsShiftedCommitmentCount(t *testing.T) { + // Ar and Bs are compressed infinity, while Krs is uncompressed infinity. + // The old fixed-offset preflight read zero halfway through Krs, then gnark + // reached the actual count after the 96-byte Krs and allocated from it. + raw := make([]byte, proofCommitmentCountOffset+4+g1Len) + raw[0] = 0xc0 + raw[g1Len] = 0xc0 + raw[g1Len+g2Len] = 0x40 + binary.BigEndian.PutUint32(raw[len(raw)-4:], maxProofCommitments+1) + + if _, err := UnmarshalProof(base64.StdEncoding.EncodeToString(raw)); err == nil || + !strings.Contains(err.Error(), "Krs must use canonical compressed encoding") { + t.Fatalf("expected non-canonical Krs rejection, got %v", err) + } +} + func TestOwnershipProofRoundTripIntegration(t *testing.T) { if os.Getenv("PROOF_TOOL_RUN_FULL_PROOF") != "1" { t.Skip("set PROOF_TOOL_RUN_FULL_PROOF=1 to run the full ownership Groth16 proof") diff --git a/internal/streampk/index.go b/internal/streampk/index.go index a918ba3..8b7041b 100644 --- a/internal/streampk/index.go +++ b/internal/streampk/index.go @@ -16,5 +16,8 @@ func BuildIndex(path string) (*Index, error) { } func ValidateIndex(idx *Index) error { - return proofassets.ValidatePKIndex(idx) + if err := proofassets.ValidatePKIndex(idx); err != nil { + return err + } + return proofassets.ValidatePKIndexAllocations(idx) } diff --git a/internal/streampk/keysource.go b/internal/streampk/keysource.go index 035455d..af31fd4 100644 --- a/internal/streampk/keysource.go +++ b/internal/streampk/keysource.go @@ -123,6 +123,17 @@ func (ks *KeySource) loadSmallFields(config openConfig) error { if err := g1Decoder.Decode(&ks.delta); err != nil { return fmt.Errorf("decode Delta: %w", err) } + // Subgroup checks are skipped for throughput on a proving key that callers + // are expected to have digest-authenticated first. On-curve validation is + // cheap and is kept, so a point that is neither a valid curve point nor in + // the authenticated key cannot silently enter a multi-scalar + // multiplication. See internal/msmengine/serialize.go, which makes the same + // trade explicitly. + for name, point := range map[string]*curve.G1Affine{"Alpha": &ks.alpha, "Beta": &ks.beta, "Delta": &ks.delta} { + if !point.IsOnCurve() { + return fmt.Errorf("%s is not on the G1 curve", name) + } + } kSec := ks.idx.Sections["K"] g2Off := kSec.Offset + kSec.Len @@ -137,6 +148,11 @@ func (ks *KeySource) loadSmallFields(config openConfig) error { if err := g2Decoder.Decode(&ks.g2delta); err != nil { return fmt.Errorf("decode G2.Delta: %w", err) } + for name, point := range map[string]*curve.G2Affine{"G2.Beta": &ks.g2beta, "G2.Delta": &ks.g2delta} { + if !point.IsOnCurve() { + return fmt.Errorf("%s is not on the G2 curve", name) + } + } g2bSec := ks.idx.Sections["G2B"] infOff := g2bSec.Offset + g2bSec.Len @@ -167,15 +183,16 @@ func decodeDomainHeader(header []byte, precompute bool) (fft.Domain, error) { if flag := header[DomainHeaderBytes-1]; flag > 1 { return fft.Domain{}, fmt.Errorf("decode domain: precompute flag byte %d is not canonical", flag) } + // Always decode without precompute first. fft.Domain.ReadFrom precomputes + // twiddle and coset tables (make([]fr.Element, Cardinality) ×2) the moment + // it reads Cardinality, before any validation — a hostile cardinality of + // 2^32 would allocate ~274 GB before being rejected. Decode the header, + // validate the cardinality is canonical (power of two with a real FFT + // generator, bounding it to the field's 2-adicity), and only then rebuild + // the precomputed tables if the caller asked for them. var domain fft.Domain reader := bytes.NewReader(header) - var err error - if precompute { - _, err = domain.ReadFrom(reader) - } else { - _, err = domain.ReadFromWithoutPrecompute(reader) - } - if err != nil { + if _, err := domain.ReadFromWithoutPrecompute(reader); err != nil { return fft.Domain{}, fmt.Errorf("decode domain: %w", err) } if reader.Len() != 0 { @@ -184,6 +201,13 @@ func decodeDomainHeader(header []byte, precompute bool) (fft.Domain, error) { if err := validateCanonicalDomain(&domain); err != nil { return fft.Domain{}, fmt.Errorf("decode domain: %w", err) } + if precompute { + precomputed := fft.NewDomain(domain.Cardinality) + if precomputed.Cardinality != domain.Cardinality { + return fft.Domain{}, fmt.Errorf("decode domain: precompute cardinality mismatch") + } + domain = *precomputed + } return domain, nil } diff --git a/scripts/bootstrap-vendor.sh b/scripts/bootstrap-vendor.sh index 44558ab..3eb7626 100644 --- a/scripts/bootstrap-vendor.sh +++ b/scripts/bootstrap-vendor.sh @@ -2,7 +2,8 @@ # Regenerates vendor/ from go.mod and applies the reviewed browser-prover patches # (gnark ProveStream/MSM seam, opt-W2 domain decoding, opt-W3 CCS release, and # opt-W1 dispatch-before-FFT scheduling/yields, opt-W6 scoped computeH -# coset-table reuse and opt-C8 constant byte-operation folding). +# coset-table reuse, opt-C8 constant byte-operation folding, and the native +# MPC ceremony Phase 1/Phase 2 parallel hot paths). # vendor/ is # gitignored; this script is the ONLY supported way to (re)create it. # A plain `go mod vendor` produces a tree WITHOUT the streaming prover and @@ -19,9 +20,12 @@ PATCHES=( experiments/wasm-prover/patches/domain-read-no-precompute.patch experiments/wasm-prover/patches/release-ccs-after-solve.patch experiments/wasm-prover/patches/dispatch-before-fft.patch - experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch - experiments/wasm-prover/patches/uints-constant-fold.patch - experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch + experiments/wasm-prover/patches/uints-constant-fold.patch + experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch + experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch ) for patch in "${PATCHES[@]}"; do diff --git a/scripts/build-mpc-ceremony-release.sh b/scripts/build-mpc-ceremony-release.sh index 24258cf..cad4c1c 100755 --- a/scripts/build-mpc-ceremony-release.sh +++ b/scripts/build-mpc-ceremony-release.sh @@ -171,8 +171,8 @@ if [[ ! -x "$GO_BIN" || -L "$GO_BIN" ]]; then exit 1 fi GO_VERSION=$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOVERSION) -if [[ "$GO_VERSION" != "go1.26.5" ]]; then - echo "FAIL: release build requires go1.26.5, found $GO_VERSION" >&2 +if [[ "$GO_VERSION" != "go1.26.6" ]]; then + echo "FAIL: release build requires go1.26.6, found $GO_VERSION" >&2 exit 1 fi GO_HOST_OS=$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOHOSTOS) @@ -185,10 +185,10 @@ if [[ -n "$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOF echo "FAIL: release build requires an empty GOEXPERIMENT" >&2 exit 1 fi -EXPECTED_GO_SHA256=8da5fd321795754b994c64e3eb8a5a14ff47bd285559a7e876f3c79abafc67f9 -EXPECTED_COMPILE_SHA256=10c67b9de41c1e546b9bf416ceef410e5e3dd87a76d129b08b74a9570db9c463 -EXPECTED_LINK_SHA256=e58a36e6550a32ed7175cd6e2a1824dc66c034d1e3539ebeac8af719a9150d5d -EXPECTED_ASM_SHA256=0c9a07447aba3ed1df7a0a3e85f6e003d9bf312d2936dfc4b79e3d81e8ca7636 +EXPECTED_GO_SHA256=29e6e0b8be61beb1489ceae62b304343566de8a1dc700af74bde7aeb9c80ad45 +EXPECTED_COMPILE_SHA256=73da54c06c0702ae7c8cff309dd3958980af7ae0307cf319d7d0cb2bbd3fafd2 +EXPECTED_LINK_SHA256=048670775edfd89c6551149c197816dacdcc12c518b29ff1c533751b2dc4b976 +EXPECTED_ASM_SHA256=769ac2d73d09b7cc5479acdeb9f168c1772743420ffca2d9e990a9b0348d2836 GO_TOOL_DIR=$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOTOOLDIR) verify_tool_hash() { local path=$1 diff --git a/scripts/check-vendor-drift.sh b/scripts/check-vendor-drift.sh index 8401c2c..a04dd41 100755 --- a/scripts/check-vendor-drift.sh +++ b/scripts/check-vendor-drift.sh @@ -2,8 +2,8 @@ # Verifies that vendor/ is exactly `go mod vendor` output plus # the reviewed patches under experiments/wasm-prover/patches. The vendored # dependencies contain ProveStream/MSM plus opt-W2 domain-decoding, opt-W3 -# CCS-release, opt-W1 scheduling/yield, opt-W6 computeH table-lifetime, and -# opt-C8 constant byte-operation folding seams; +# CCS-release, opt-W1 scheduling/yield, opt-W6 computeH table-lifetime, +# opt-C8 constant byte-operation folding, and native MPC ceremony parallelism; # regenerating vendor/ without this check in place silently deletes the prover. # # Fails (exit 1) on any drift in either direction: an unmirrored vendor edit, @@ -16,9 +16,12 @@ PATCHES=( experiments/wasm-prover/patches/domain-read-no-precompute.patch experiments/wasm-prover/patches/release-ccs-after-solve.patch experiments/wasm-prover/patches/dispatch-before-fft.patch - experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch - experiments/wasm-prover/patches/uints-constant-fold.patch - experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch + experiments/wasm-prover/patches/uints-constant-fold.patch + experiments/wasm-prover/patches/computeh-parallel-transforms.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch + experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch + experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch ) for patch in "${PATCHES[@]}"; do diff --git a/scripts/generate-go-sbom/main.go b/scripts/generate-go-sbom/main.go index c74e846..02e5983 100644 --- a/scripts/generate-go-sbom/main.go +++ b/scripts/generate-go-sbom/main.go @@ -30,6 +30,9 @@ var gnarkPatchPaths = []string{ "experiments/wasm-prover/patches/computeh-scoped-coset-tables.patch", "experiments/wasm-prover/patches/uints-constant-fold.patch", "experiments/wasm-prover/patches/computeh-parallel-transforms.patch", + "experiments/wasm-prover/patches/mpc-phase1-parallel-update.patch", + "experiments/wasm-prover/patches/mpc-phase1-parallel-codec.patch", + "experiments/wasm-prover/patches/mpc-phase2-parallel-initialize.patch", } type bom struct { diff --git a/scripts/mpc-demo-init.sh b/scripts/mpc-demo-init.sh new file mode 100755 index 0000000..b1d64d2 --- /dev/null +++ b/scripts/mpc-demo-init.sh @@ -0,0 +1,61 @@ +#!/usr/bin/env bash +# Builds the CLI and runs a rehearsal `init` end to end into a fresh root. +# +# Rehearsal only. It generates same-host identities and keys, which are not +# production enrollment and prove nothing about participant independence. +# Never point this at a production ceremony root. +# +# usage: scripts/mpc-demo-init.sh [ROOT] [PARTICIPANTS] +set -euo pipefail +umask 077 + +ROOT=${1:-/tmp/mpcdemo} +PARTICIPANTS=${2:-3} +REPO_ROOT=$(cd "$(dirname "$0")/.." && pwd) + +# go build embeds vcs.revision and vcs.modified; `go run` does not, and the +# binary refuses to start without them (internal/mpcceremony/software.go). +export PATH="$HOME/.local/go/bin:$PATH" +command -v go >/dev/null || { echo "go not found on PATH" >&2; exit 1; } + +[ -e "$ROOT" ] && { echo "refusing to reuse existing root: $ROOT" >&2; exit 1; } + +BIN="$ROOT/bin/mpc-ceremony" +mkdir -p "$ROOT/bin" + +echo "==> building CLI" +(cd "$REPO_ROOT" && go build -o "$BIN" ./cmd/mpc-ceremony) + +echo "==> generating rehearsal identities and canonical config" +(cd "$REPO_ROOT" && go run ./scripts/mpc-rehearsal-config \ + --out-dir "$ROOT/config-root" \ + --participants "$PARTICIPANTS") + +CONFIG="$ROOT/config-root/config" +KEYS="$ROOT/config-root/keys" + +# The key id must match participants.json, not an invented name. Read it back +# rather than hardcoding it. +COORDINATOR_KEY_ID=$( + python3 -c 'import json,sys; print(json.load(open(sys.argv[1]))["coordinator"]["key_id"])' \ + "$CONFIG/participants.json" +) +echo "==> coordinator key id: $COORDINATOR_KEY_ID" + +# Signed claim, so use an observed UTC time rather than a fabricated one. +CREATED_AT=$(date -u +%Y-%m-%dT%H:%M:%SZ) + +echo "==> init (compiles the K=21 circuit; expect several minutes)" +MPC_CEREMONY_DEBUG=${MPC_CEREMONY_DEBUG:-} "$BIN" --format json init \ + --key-version ownership-destination-v2 \ + --participants "$CONFIG/participants.json" \ + --policy "$CONFIG/policy.json" \ + --coordinator-key-id "$COORDINATOR_KEY_ID" \ + --coordinator-signing-key "$KEYS/coordinator.ed25519.private.hex" \ + --created-at "$CREATED_AT" \ + --mode rehearsal \ + --out-dir "$ROOT/public" + +echo +echo "==> artifacts" +find "$ROOT/public" -type f -printf '%10s %p\n' | sort -k2 diff --git a/scripts/mpc-finalization-evidence/golden_vector_test.go b/scripts/mpc-finalization-evidence/golden_vector_test.go new file mode 100644 index 0000000..cf35218 --- /dev/null +++ b/scripts/mpc-finalization-evidence/golden_vector_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "encoding/hex" + "testing" + + "proof-tool/internal/circuit/ownership" + "proof-tool/internal/circuit/ownershipdest" + "proof-tool/internal/mpcceremony" +) + +// TestGeneratedVectorMatchesPinnedGolden pins the relationship this command +// depends on and that nothing else checked. +// +// The generator derives a credential from a hardcoded master key at a hardcoded +// path, while PublicFinalizationEvidence.Validate accepts only the credential +// pinned in mpcceremony.GoldenPublicCredentialHex. Those are two independent +// constants that must agree. They did not: the generator derived at account 3, +// role 2 while the pinned credential is account 0, role 0, so every attempt to +// finalize a ceremony failed with "public evidence does not use the exact +// repository golden public vector" — after the multi-hour replay that produces +// the keys, which is the only point at which it is reachable. +func TestGeneratedVectorMatchesPinnedGolden(t *testing.T) { + master, err := ownership.DecodeMasterXPrvHex(goldenMasterXPrvHex) + if err != nil { + t.Fatal(err) + } + credential, err := ownership.DeriveCredential(master, goldenPath) + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(credential[:]); got != mpcceremony.GoldenPublicCredentialHex { + t.Fatalf("derived credential %s, pinned golden %s", got, mpcceremony.GoldenPublicCredentialHex) + } + + destination, err := ownershipdest.DecodeDestinationAddressV1Hex(goldenDestinationHex) + if err != nil { + t.Fatal(err) + } + if got := hex.EncodeToString(destination); got != mpcceremony.GoldenPublicDestinationHex { + t.Fatalf("destination %s, pinned golden %s", got, mpcceremony.GoldenPublicDestinationHex) + } +} diff --git a/scripts/mpc-finalization-evidence/main.go b/scripts/mpc-finalization-evidence/main.go index cde6468..a84e7e7 100644 --- a/scripts/mpc-finalization-evidence/main.go +++ b/scripts/mpc-finalization-evidence/main.go @@ -11,9 +11,12 @@ import ( "errors" "flag" "fmt" + "io" "os" "path/filepath" + "github.com/consensys/gnark/logger" + "proof-tool/internal/circuit/ownership" "proof-tool/internal/circuit/ownershipdest" "proof-tool/internal/mpcceremony" @@ -22,13 +25,40 @@ import ( const resultSchema = "proof-tool-mpc-public-evidence-generation-result-v1" +// The repository's public golden test vector. This is not user wallet +// material; keeping it in this separate helper is what proves the +// participant and coordinator binary never handles a wallet secret. +// +// These must derive to mpcceremony.GoldenPublicCredentialHex and equal +// mpcceremony.GoldenPublicDestinationHex, because +// PublicFinalizationEvidence.Validate accepts nothing else. They are named +// here rather than inlined so golden_vector_test.go can assert that +// agreement; when they were inlined the path drifted from the pinned +// credential and finalization became unreachable. +const ( + goldenMasterXPrvHex = "c065afd2832cd8b087c4d9ab7011f481ee1e0721e78ea5dd609f3ab3f156d245" + + "d176bd8fd4ec60b4731c3918a2a72a0226c0cd119ec35b47e4d55884667f552a" + + "23f7fdcd4a10c6cd2c7393ac61d877873e248f417634aa3d812af327ffe9d620" + goldenDestinationHex = "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111000000" + + "0000000000000000000000000000000000000000000000000000" +) + +var goldenPath = ownership.Path{Account: 0, Role: 0, Index: 0} + func main() { + // gnark defaults its global logger to stdout. Keep stdout exclusively for + // the helper's single JSON result so the ceremony runner can parse it. + configureLibraryLogging(os.Stderr) if err := run(); err != nil { fmt.Fprintln(os.Stderr, "error:", err) os.Exit(1) } } +func configureLibraryLogging(stderr io.Writer) { + logger.SetOutput(stderr) +} + func run() error { fs := flag.NewFlagSet("mpc-finalization-evidence", flag.ContinueOnError) keysDir := fs.String("keys-dir", "", "preliminary final-key directory from mpc-ceremony finalize prepare") @@ -70,23 +100,15 @@ func run() error { // This is the repository's public golden test witness, not user wallet // material. Keeping it in this separate rehearsal helper proves that the // participant/coordinator ceremony binary never handles a wallet secret. - master, err := ownership.DecodeMasterXPrvHex( - "c065afd2832cd8b087c4d9ab7011f481ee1e0721e78ea5dd609f3ab3f156d245" + - "d176bd8fd4ec60b4731c3918a2a72a0226c0cd119ec35b47e4d55884667f552a" + - "23f7fdcd4a10c6cd2c7393ac61d877873e248f417634aa3d812af327ffe9d620", - ) + master, err := ownership.DecodeMasterXPrvHex(goldenMasterXPrvHex) if err != nil { return err } - destination, err := ownershipdest.DecodeDestinationAddressV1Hex( - "010038ff22c6562b1277ef0d3eb3b8b4892523eeba04d0ef0c9d7da111000000" + - "0000000000000000000000000000000000000000000000000000", - ) + destination, err := ownershipdest.DecodeDestinationAddressV1Hex(goldenDestinationHex) if err != nil { return err } - path := ownership.Path{Account: 3, Role: 2, Index: 0} - credential, err := ownership.DeriveCredential(master, path) + credential, err := ownership.DeriveCredential(master, goldenPath) if err != nil { return err } @@ -98,7 +120,7 @@ func run() error { if err != nil { return err } - assignment, err := ownershipdest.Assignment(master, path, destination, publicInput) + assignment, err := ownershipdest.Assignment(master, goldenPath, destination, publicInput) if err != nil { return err } diff --git a/scripts/mpc-finalization-evidence/main_test.go b/scripts/mpc-finalization-evidence/main_test.go new file mode 100644 index 0000000..1344c46 --- /dev/null +++ b/scripts/mpc-finalization-evidence/main_test.go @@ -0,0 +1,28 @@ +package main + +import ( + "bytes" + "strings" + "testing" + + gnarklogger "github.com/consensys/gnark/logger" + "github.com/rs/zerolog" +) + +func TestConfigureLibraryLoggingKeepsDiagnosticsOffStdout(t *testing.T) { + var stdout bytes.Buffer + var stderr bytes.Buffer + gnarklogger.Set(zerolog.New(&stdout)) + t.Cleanup(gnarklogger.Disable) + + configureLibraryLogging(&stderr) + diagnosticLogger := gnarklogger.Logger() + diagnosticLogger.Debug().Msg("gnark diagnostic") + + if stdout.Len() != 0 { + t.Fatalf("gnark wrote %q to stdout", stdout.String()) + } + if !strings.Contains(stderr.String(), "gnark diagnostic") { + t.Fatalf("gnark diagnostic missing from stderr: %q", stderr.String()) + } +} diff --git a/scripts/mpc-rehearsal-config/main.go b/scripts/mpc-rehearsal-config/main.go index fda685d..f4651d8 100644 --- a/scripts/mpc-rehearsal-config/main.go +++ b/scripts/mpc-rehearsal-config/main.go @@ -5,30 +5,13 @@ package main import ( - "crypto/ed25519" - "crypto/rand" - "encoding/hex" - "errors" "flag" "fmt" "os" - "path/filepath" - "runtime" - "proof-tool/internal/mpcceremony" + "proof-tool/internal/mpcrehearsal" ) -const ( - minRehearsalParticipants = 3 - maxRehearsalParticipants = 20 - minRehearsalBeaconLead = 60 -) - -type generatedIdentity struct { - identity mpcceremony.Identity - privateKey ed25519.PrivateKey -} - func main() { outDir := flag.String("out-dir", "", "fresh output directory") participantCount := flag.Int("participants", 3, "number of rehearsal participants (3-20)") @@ -53,216 +36,6 @@ func main() { fmt.Printf("OK: generated rehearsal-only identities and canonical config in %s\n", *outDir) } -func generate(outDir string, participantCount int, beaconWitnessLead uint32) (err error) { - if participantCount < minRehearsalParticipants || - participantCount > maxRehearsalParticipants { - return fmt.Errorf( - "participants must be between %d and %d", - minRehearsalParticipants, - maxRehearsalParticipants, - ) - } - if beaconWitnessLead < minRehearsalBeaconLead { - return fmt.Errorf( - "beacon witness lead must be at least %d seconds", - minRehearsalBeaconLead, - ) - } - if err := os.Mkdir(outDir, 0o700); err != nil { - return fmt.Errorf("create fresh rehearsal config root: %w", err) - } - removeRoot := true - defer func() { - if err != nil && removeRoot { - _ = os.RemoveAll(outDir) - } - }() - keyDir := filepath.Join(outDir, "keys") - configDir := filepath.Join(outDir, "config") - for _, path := range []string{keyDir, configDir} { - if err := os.Mkdir(path, 0o700); err != nil { - return err - } - } - - newIdentity := func(id, displayName string) (generatedIdentity, error) { - publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) - if err != nil { - return generatedIdentity{}, err - } - identity, err := mpcceremony.NewIdentity( - id, - displayName, - id+"-key", - publicKey, - ) - if err != nil { - return generatedIdentity{}, err - } - return generatedIdentity{identity: identity, privateKey: privateKey}, nil - } - - coordinator, err := newIdentity("coordinator", "Local Rehearsal Coordinator") - if err != nil { - return err - } - releaseSigner, err := newIdentity("release-signer", "Local Rehearsal Release Signer") - if err != nil { - return err - } - auditor1, err := newIdentity("auditor-01", "Local Rehearsal Auditor 01") - if err != nil { - return err - } - auditor2, err := newIdentity("auditor-02", "Local Rehearsal Auditor 02") - if err != nil { - return err - } - witness1, err := newIdentity("witness-01", "Local Rehearsal Public Witness 01") - if err != nil { - return err - } - witness2, err := newIdentity("witness-02", "Local Rehearsal Public Witness 02") - if err != nil { - return err - } - mirror1, err := newIdentity("mirror-01", "Local Rehearsal Mirror Operator 01") - if err != nil { - return err - } - mirror2, err := newIdentity("mirror-02", "Local Rehearsal Mirror Operator 02") - if err != nil { - return err - } - generated := []generatedIdentity{ - coordinator, - releaseSigner, - auditor1, - auditor2, - witness1, - witness2, - mirror1, - mirror2, - } - participants := make([]mpcceremony.Participant, 0, participantCount) - participantIDs := make([]string, 0, participantCount) - for index := 1; index <= participantCount; index++ { - id := fmt.Sprintf("participant-%02d", index) - participant, err := newIdentity(id, "Local Rehearsal "+id) - if err != nil { - return err - } - generated = append(generated, participant) - participants = append(participants, mpcceremony.Participant{Identity: participant.identity}) - participantIDs = append(participantIDs, id) - } - - for _, item := range generated { - seedPath := filepath.Join(keyDir, item.identity.ID+".ed25519.private.hex") - if err := writeNoReplace( - seedPath, - []byte(hex.EncodeToString(item.privateKey.Seed())+"\n"), - 0o600, - ); err != nil { - return err - } - publicPath := filepath.Join(keyDir, item.identity.ID+".ed25519.public.hex") - if err := writeNoReplace( - publicPath, - []byte(item.identity.Ed25519PublicKeyHex+"\n"), - 0o600, - ); err != nil { - return err - } - } - - enrollment := mpcceremony.InitParticipants{ - Coordinator: coordinator.identity, - ReleaseSigner: releaseSigner.identity, - Auditors: []mpcceremony.Identity{auditor1.identity, auditor2.identity}, - Roster: participants, - } - policy := mpcceremony.InitPolicy{ - Phase1Policy: mpcceremony.PhasePolicy{ - Participants: participantIDs, - Minimum: uint8(participantCount), - }, - Phase2Policy: mpcceremony.PhasePolicy{ - Participants: append([]string(nil), participantIDs...), - Minimum: uint8(participantCount), - }, - BeaconPolicy: mpcceremony.BeaconPolicy{ - Provider: mpcceremony.BeaconProviderDrand, - Network: mpcceremony.BeaconNetworkQuicknet, - ChainHashHex: mpcceremony.BeaconQuicknetChainHash, - PublicKeyHex: mpcceremony.BeaconQuicknetPublicKey, - Scheme: mpcceremony.BeaconQuicknetScheme, - GenesisTimeUnix: mpcceremony.BeaconQuicknetGenesis, - PeriodSeconds: mpcceremony.BeaconQuicknetPeriod, - Extraction: mpcceremony.BeaconExtractionV1, - MinimumChallengeBytes: 32, - MinimumWitnessLeadSeconds: beaconWitnessLead, - FutureRoundRequired: true, - }, - } - environment := mpcceremony.ContributionEnvironment{ - OS: runtime.GOOS, - Architecture: runtime.GOARCH, - EntropySource: "operating-system-csprng", - SwapDisabled: true, - CrashDumpsDisabled: true, - TelemetryDisabled: true, - EphemeralEnvironment: true, - EphemeralDestructionRequired: true, - } - for name, value := range map[string]any{ - "participants.json": enrollment, - "policy.json": policy, - "environment.json": environment, - } { - data, err := mpcceremony.MarshalCanonical(value) - if err != nil { - return err - } - if err := writeNoReplace(filepath.Join(configDir, name), data, 0o600); err != nil { - return err - } - } - if err := writeNoReplace( - filepath.Join(outDir, "participant-count.txt"), - []byte(fmt.Sprintf("%d\n", participantCount)), - 0o600, - ); err != nil { - return err - } - removeRoot = false - return nil -} - -func writeNoReplace(path string, data []byte, mode os.FileMode) error { - if len(data) == 0 { - return errors.New("refusing to write empty rehearsal config") - } - file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, mode) - if err != nil { - return err - } - remove := true - defer func() { - _ = file.Close() - if remove { - _ = os.Remove(path) - } - }() - if _, err := file.Write(data); err != nil { - return err - } - if err := file.Sync(); err != nil { - return err - } - if err := file.Close(); err != nil { - return err - } - remove = false - return nil +func generate(outDir string, participantCount int, beaconWitnessLead uint32) error { + return mpcrehearsal.Generate(outDir, participantCount, beaconWitnessLead) } diff --git a/scripts/run-mpc-k21-local-rehearsal.sh b/scripts/run-mpc-k21-local-rehearsal.sh index 21509e0..71a77ee 100755 --- a/scripts/run-mpc-k21-local-rehearsal.sh +++ b/scripts/run-mpc-k21-local-rehearsal.sh @@ -2035,6 +2035,7 @@ case "$STAGE" in --published-at "$PUBLISHED_AT" \ --coordinator-signing-key "$COORDINATOR_PRIVATE_KEY" \ --transcript-dir "$TRANSCRIPT" + write_state "$STATE_DIR/phase2-published-epoch.txt" "$PUBLISHED_EPOCH" PREPARED_EPOCH=$(step_epoch finalize-prepare "$((PUBLISHED_EPOCH + 1))") run_step finalize-prepare \ finalize prepare \ diff --git a/scripts/test-all.sh b/scripts/test-all.sh index 294b546..0be0538 100755 --- a/scripts/test-all.sh +++ b/scripts/test-all.sh @@ -74,7 +74,7 @@ else # multi / destination round-trip integration tests (positive + tamper # cases). This is the strongest local evidence that proof generation works. run_step "go test (full, incl. real ownership Groth16 round-trips)" \ - env PROOF_TOOL_RUN_FULL_PROOF=1 go test ./... + env PROOF_TOOL_RUN_FULL_PROOF=1 go test -timeout 55m ./... fi run_step "wasm prover builds" env GOOS=js GOARCH=wasm go build -o /dev/null ./cmd/wasm-prover diff --git a/scripts/verify-mpc-build-metadata/main.go b/scripts/verify-mpc-build-metadata/main.go index 9c640d4..d253626 100644 --- a/scripts/verify-mpc-build-metadata/main.go +++ b/scripts/verify-mpc-build-metadata/main.go @@ -32,7 +32,7 @@ import ( ) const ( - productionGoVersion = "go1.26.5" + productionGoVersion = "go1.26.6" expectedBuildFlags = "-mod=vendor\x00-trimpath\x00-buildvcs=true\x00-ldflags=-buildid=" ) @@ -70,6 +70,9 @@ var ( "computeh-scoped-coset-tables.patch", "uints-constant-fold.patch", "computeh-parallel-transforms.patch", + "mpc-phase1-parallel-update.patch", + "mpc-phase1-parallel-codec.patch", + "mpc-phase2-parallel-initialize.patch", } ) @@ -244,16 +247,16 @@ func verifyPlainIdentity(dir, mode, commit, tag, fingerprint string) error { func verifyToolchainChecksums(path string) error { const expected = "" + - "8da5fd321795754b994c64e3eb8a5a14ff47bd285559a7e876f3c79abafc67f9 go\n" + - "10c67b9de41c1e546b9bf416ceef410e5e3dd87a76d129b08b74a9570db9c463 compile\n" + - "e58a36e6550a32ed7175cd6e2a1824dc66c034d1e3539ebeac8af719a9150d5d link\n" + - "0c9a07447aba3ed1df7a0a3e85f6e003d9bf312d2936dfc4b79e3d81e8ca7636 asm\n" + "29e6e0b8be61beb1489ceae62b304343566de8a1dc700af74bde7aeb9c80ad45 go\n" + + "73da54c06c0702ae7c8cff309dd3958980af7ae0307cf319d7d0cb2bbd3fafd2 compile\n" + + "048670775edfd89c6551149c197816dacdcc12c518b29ff1c533751b2dc4b976 link\n" + + "769ac2d73d09b7cc5479acdeb9f168c1772743420ffca2d9e990a9b0348d2836 asm\n" data, err := os.ReadFile(path) if err != nil { return err } if string(data) != expected { - return errors.New("toolchain-checksums.sha256 does not identify the approved Go 1.26.5 linux/amd64 toolchain") + return errors.New("toolchain-checksums.sha256 does not identify the approved Go 1.26.6 linux/amd64 toolchain") } return nil } diff --git a/scripts/verify-mpc-ceremony-reproducible.sh b/scripts/verify-mpc-ceremony-reproducible.sh index 7dbf6d8..c3de2f7 100755 --- a/scripts/verify-mpc-ceremony-reproducible.sh +++ b/scripts/verify-mpc-ceremony-reproducible.sh @@ -160,8 +160,8 @@ ACTIVE_GOROOT=$(env -u GOROOT \ go env GOROOT) GO_BIN="$ACTIVE_GOROOT/bin/go" if [[ ! -x "$GO_BIN" || -L "$GO_BIN" || - "$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOVERSION)" != "go1.26.5" ]]; then - echo "FAIL: semantic verification requires the approved Go 1.26.5 toolchain" >&2 + "$(env -u GOROOT CGO_ENABLED=0 GOARCH=amd64 GOENV=off GOEXPERIMENT= GOFIPS140=off GOOS=linux GOAMD64=v1 GOTOOLCHAIN=local "$GO_BIN" env GOVERSION)" != "go1.26.6" ]]; then + echo "FAIL: semantic verification requires the approved Go 1.26.6 toolchain" >&2 exit 1 fi